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:
@@ -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>
|
||||
|
||||
@@ -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 på 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 på dina influencer-kampanjer baserat på 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 på samarbete *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="T.ex. Sommarkampanj 2025"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Beskrivning</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Kort beskrivning av kampanjen..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="brand_name">Varumärke</Label>
|
||||
<Input
|
||||
id="brand_name"
|
||||
value={formData.brand_name || ''}
|
||||
onChange={(e) => setFormData({ ...formData, brand_name: e.target.value })}
|
||||
placeholder="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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 på ditt konto.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Notification settings */}
|
||||
<TabsContent value="notifications">
|
||||
<NotificationSettings />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 || []
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 || [],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { classifyGift, TAX_FREE_PROMO_THRESHOLD, getBookingTypeDisplayText } from '@/lib/benefits/gift-classifier'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { Gift, CheckCircle, XCircle, AlertCircle, HelpCircle, AlertTriangle } from 'lucide-react'
|
||||
import type { CreateGiftInput, GiftInput, GiftClassification } from '@/types'
|
||||
|
||||
interface GiftFormProps {
|
||||
onSubmit: (data: CreateGiftInput) => Promise<void>
|
||||
initialData?: Partial<CreateGiftInput & { returned?: boolean }>
|
||||
isLoading?: boolean
|
||||
isLightMode?: boolean
|
||||
}
|
||||
|
||||
export default function GiftForm({ onSubmit, initialData, isLoading, isLightMode }: GiftFormProps) {
|
||||
const [formData, setFormData] = useState<CreateGiftInput>({
|
||||
date: initialData?.date || new Date().toISOString().split('T')[0],
|
||||
brand_name: initialData?.brand_name || '',
|
||||
description: initialData?.description || '',
|
||||
estimated_value: initialData?.estimated_value || 0,
|
||||
has_motprestation: initialData?.has_motprestation || false,
|
||||
used_in_business: initialData?.used_in_business || false,
|
||||
used_privately: initialData?.used_privately || false,
|
||||
is_simple_promo: initialData?.is_simple_promo || false,
|
||||
})
|
||||
const [returned, setReturned] = useState(initialData?.returned || false)
|
||||
const [valueOverridden, setValueOverridden] = useState(false)
|
||||
|
||||
// Compute classification from form data (derived state, no need for useEffect)
|
||||
const classification = useMemo<GiftClassification | null>(() => {
|
||||
if (formData.estimated_value > 0) {
|
||||
const input: GiftInput = {
|
||||
estimatedValue: formData.estimated_value,
|
||||
hasMotprestation: formData.has_motprestation,
|
||||
usedInBusiness: formData.used_in_business,
|
||||
usedPrivately: formData.used_privately,
|
||||
isSimplePromoItem: formData.is_simple_promo || false,
|
||||
}
|
||||
return classifyGift(input)
|
||||
}
|
||||
return null
|
||||
}, [formData.estimated_value, formData.has_motprestation, formData.used_in_business, formData.used_privately, formData.is_simple_promo])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await onSubmit({ ...formData, returned } as CreateGiftInput & { returned?: boolean })
|
||||
}
|
||||
|
||||
const updateField = <K extends keyof CreateGiftInput>(field: K, value: CreateGiftInput[K]) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Gift className="h-5 w-5" />
|
||||
Produktinformation
|
||||
</CardTitle>
|
||||
<CardDescription>Ange information om gåvan eller produkten du fått</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date">Datum</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => updateField('date', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="brand_name">Varumärke/Företag</Label>
|
||||
<Input
|
||||
id="brand_name"
|
||||
placeholder="t.ex. Daniel Wellington"
|
||||
value={formData.brand_name}
|
||||
onChange={(e) => updateField('brand_name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Beskrivning</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="t.ex. Klocka, modell Classic Petite"
|
||||
value={formData.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="estimated_value">Uppskattat marknadsvärde (SEK)</Label>
|
||||
<Input
|
||||
id="estimated_value"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
value={formData.estimated_value || ''}
|
||||
onChange={(e) => updateField('estimated_value', parseFloat(e.target.value) || 0)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skattefritt om enklare reklamgåva under {formatCurrency(TAX_FREE_PROMO_THRESHOLD)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="is_simple_promo"
|
||||
checked={formData.is_simple_promo || false}
|
||||
onCheckedChange={(checked) => updateField('is_simple_promo', checked)}
|
||||
/>
|
||||
<Label htmlFor="is_simple_promo" className="flex items-center gap-2">
|
||||
Enklare reklamgåva
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-12">
|
||||
T.ex. penna, mugg, t-shirt med företagslogo - inte exklusiva produkter
|
||||
</p>
|
||||
|
||||
{/* Returned toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="returned"
|
||||
checked={returned}
|
||||
onCheckedChange={setReturned}
|
||||
/>
|
||||
<Label htmlFor="returned">Returnerad</Label>
|
||||
</div>
|
||||
{returned && (
|
||||
<p className="text-xs text-success ml-12">
|
||||
Returnerade gåvor räknas inte som skattepliktig inkomst.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Value override warning */}
|
||||
{valueOverridden && (
|
||||
<div className="flex items-start gap-2 p-3 bg-warning/10 border border-warning/30 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-warning">
|
||||
Skatteverket kräver marknadsvärde vid mottagning (inkl. moms). Lägre värdering ökar revisionsrisken.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Decision Tree Questions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skatteklassificering</CardTitle>
|
||||
<CardDescription>Svara på frågorna nedan för korrekt skattehantering</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Question 1: Motprestation */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="has_motprestation" className="font-medium">
|
||||
Fanns krav på att du skulle posta om denna produkt?
|
||||
</Label>
|
||||
<Switch
|
||||
id="has_motprestation"
|
||||
checked={formData.has_motprestation}
|
||||
onCheckedChange={(checked) => updateField('has_motprestation', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
T.ex. sponsrat inlägg, samarbete, eller annat krav på att nämna/visa produkten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Question 2: Business use - hidden for light mode */}
|
||||
{!isLightMode && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="used_in_business" className="font-medium">
|
||||
Använder du produkten i din content-produktion?
|
||||
</Label>
|
||||
<Switch
|
||||
id="used_in_business"
|
||||
checked={formData.used_in_business}
|
||||
onCheckedChange={(checked) => updateField('used_in_business', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">T.ex. som rekvisita, utrustning, eller i bakgrunden</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question 3: Private use */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="used_privately" className="font-medium">
|
||||
Använder du produkten privat?
|
||||
</Label>
|
||||
<Switch
|
||||
id="used_privately"
|
||||
checked={formData.used_privately}
|
||||
onCheckedChange={(checked) => updateField('used_privately', checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">T.ex. bär klockan dagligen, använder sminket privat</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Classification Preview */}
|
||||
{classification && (
|
||||
<Card
|
||||
className={
|
||||
classification.taxable
|
||||
? classification.deductibleAsExpense
|
||||
? 'border-warning/50 bg-warning/5'
|
||||
: 'border-destructive/50 bg-destructive/5'
|
||||
: 'border-success/50 bg-success/5'
|
||||
}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{classification.taxable ? (
|
||||
classification.deductibleAsExpense ? (
|
||||
<AlertCircle className="h-5 w-5 text-warning" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
)
|
||||
) : (
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
)}
|
||||
Klassificering
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={classification.taxable ? 'destructive' : 'default'}>
|
||||
{classification.taxable ? 'Skattepliktig' : 'Skattefri'}
|
||||
</Badge>
|
||||
{classification.deductibleAsExpense && <Badge variant="outline">Avdragsgill</Badge>}
|
||||
<Badge variant="secondary">{getBookingTypeDisplayText(classification.bookingType)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Förklaring:</p>
|
||||
<p className="text-muted-foreground">{classification.reasoning}</p>
|
||||
</div>
|
||||
|
||||
{classification.taxable && (
|
||||
<div className="text-sm border-t pt-4">
|
||||
<p className="font-medium">Skatteeffekt:</p>
|
||||
<p className="text-muted-foreground">
|
||||
{isLightMode ? (
|
||||
<>Gåvoskatt: ca {formatCurrency(classification.marketValue * 0.32)} ({Math.round(32)}% av marknadsvärde {formatCurrency(classification.marketValue)}). Skatten ingår inte i din paraplyföretags hantering.</>
|
||||
) : (
|
||||
<>
|
||||
Marknadsvärdet {formatCurrency(classification.marketValue)} läggs till din beskattningsbara inkomst.
|
||||
{classification.deductibleAsExpense && ' Eftersom produkten endast används i verksamheten är den avdragsgill som kostnad.'}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" disabled={isLoading || !formData.brand_name || !formData.description}>
|
||||
{isLoading ? 'Sparar...' : initialData ? 'Uppdatera' : 'Spara gåva'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { Gift, Pencil, Trash2, Calendar, Building } from 'lucide-react'
|
||||
import type { Gift as GiftType } from '@/types'
|
||||
|
||||
interface GiftListProps {
|
||||
gifts: GiftType[]
|
||||
onEdit: (gift: GiftType) => void
|
||||
onDelete: (id: string) => Promise<void>
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
type FilterType = 'all' | 'taxable' | 'tax_free' | 'deductible'
|
||||
|
||||
export default function GiftList({ gifts, onEdit, onDelete, isDeleting }: GiftListProps) {
|
||||
const [filter, setFilter] = useState<FilterType>('all')
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [giftToDelete, setGiftToDelete] = useState<GiftType | null>(null)
|
||||
|
||||
// Filter gifts based on selection
|
||||
const filteredGifts = gifts.filter((gift) => {
|
||||
if (filter === 'all') return true
|
||||
if (filter === 'taxable') return gift.classification?.taxable
|
||||
if (filter === 'tax_free') return !gift.classification?.taxable
|
||||
if (filter === 'deductible') return gift.classification?.deductibleAsExpense
|
||||
return true
|
||||
})
|
||||
|
||||
const handleDeleteClick = (gift: GiftType) => {
|
||||
setGiftToDelete(gift)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (giftToDelete) {
|
||||
await onDelete(giftToDelete.id)
|
||||
setDeleteDialogOpen(false)
|
||||
setGiftToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('sv-SE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
if (gifts.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<Gift className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga gåvor registrerade</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Lägg till gåvor och produkter du fått för att hålla koll på skatten
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Visar {filteredGifts.length} av {gifts.length} gåvor
|
||||
</p>
|
||||
<Select value={filter} onValueChange={(value: FilterType) => setFilter(value)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Filtrera" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alla</SelectItem>
|
||||
<SelectItem value="taxable">Skattepliktiga</SelectItem>
|
||||
<SelectItem value="tax_free">Skattefria</SelectItem>
|
||||
<SelectItem value="deductible">Avdragsgilla</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Gift list */}
|
||||
<div className="space-y-3">
|
||||
{filteredGifts.map((gift) => (
|
||||
<Card key={gift.id}>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium truncate">{gift.description}</h3>
|
||||
{gift.classification?.taxable ? (
|
||||
<Badge variant="destructive" className="flex-shrink-0">
|
||||
Skattepliktig
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="flex-shrink-0">
|
||||
Skattefri
|
||||
</Badge>
|
||||
)}
|
||||
{gift.classification?.deductibleAsExpense && (
|
||||
<Badge variant="outline" className="flex-shrink-0">
|
||||
Avdragsgill
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Building className="h-3 w-3" />
|
||||
{gift.brand_name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDate(gift.date)}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatCurrency(Number(gift.estimated_value))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{gift.classification?.reasoning && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{gift.classification.reasoning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(gift)}
|
||||
title="Redigera"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteClick(gift)}
|
||||
title="Ta bort"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ta bort gåva?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Är du säker på att du vill ta bort "{giftToDelete?.description}" från{' '}
|
||||
{giftToDelete?.brand_name}? Detta kan inte ångras.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
|
||||
{isDeleting ? 'Tar bort...' : 'Ta bort'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { Gift, TrendingUp } from 'lucide-react'
|
||||
import type { GiftSummary } from '@/types'
|
||||
|
||||
interface GiftSummaryCardProps {
|
||||
summary: GiftSummary | null
|
||||
}
|
||||
|
||||
export default function GiftSummaryCard({ summary }: GiftSummaryCardProps) {
|
||||
// Don't show card if no gifts
|
||||
if (!summary || summary.total_count === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Gift className="h-5 w-5 flex-shrink-0 text-primary" />
|
||||
<div>
|
||||
<p className="font-medium">Gåvor & Förmåner</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{summary.total_count} produkter · {formatCurrency(summary.total_value)} totalt
|
||||
</p>
|
||||
{summary.taxable_count > 0 && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<TrendingUp className="h-3 w-3 text-destructive" />
|
||||
<span className="text-xs text-destructive">
|
||||
{formatCurrency(summary.taxable_value)} skattepliktig inkomst
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{summary.deductible_count > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatCurrency(summary.deductible_value)} avdragsgill kostnad
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/gifts">
|
||||
<Button variant="outline" size="sm">
|
||||
Visa
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -102,12 +102,7 @@ export function DeadlineForm({
|
||||
notes: formData.notes || null,
|
||||
is_completed: initialData?.is_completed || false,
|
||||
completed_at: initialData?.completed_at || null,
|
||||
campaign_id: null,
|
||||
deliverable_id: null,
|
||||
is_auto_generated: false,
|
||||
date_calculation_type: null,
|
||||
reference_event: null,
|
||||
offset_days: null,
|
||||
// New tax deadline fields with defaults for user-created deadlines
|
||||
tax_deadline_type: null,
|
||||
tax_period: null,
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Briefing, BriefingType, BRIEFING_TYPE_LABELS } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Upload, FileText, X, Link2, AlignLeft, Loader2, Sparkles } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface BriefingFormProps {
|
||||
campaignId: string
|
||||
briefing?: Briefing | null
|
||||
onSuccess: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function BriefingForm({ campaignId, briefing, onSuccess, onCancel }: BriefingFormProps) {
|
||||
const { toast } = useToast()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const isEditing = !!briefing
|
||||
|
||||
// Form state
|
||||
const [briefingType, setBriefingType] = useState<BriefingType>(briefing?.briefing_type || 'link')
|
||||
const [title, setTitle] = useState(briefing?.title || '')
|
||||
const [linkUrl, setLinkUrl] = useState(briefing?.briefing_type === 'link' ? briefing?.content || '' : '')
|
||||
const [textContent, setTextContent] = useState(briefing?.text_content || '')
|
||||
const [notes, setNotes] = useState(briefing?.notes || '')
|
||||
|
||||
// File upload state
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
// AI summary state
|
||||
const [aiSummary, setAiSummary] = useState('')
|
||||
const [isSummarizing, setIsSummarizing] = useState(false)
|
||||
|
||||
const handleSummarize = async () => {
|
||||
if (!textContent.trim()) {
|
||||
toast({
|
||||
title: 'Text saknas',
|
||||
description: 'Ange text att sammanfatta',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSummarizing(true)
|
||||
try {
|
||||
const response = await fetch('/api/briefings/summarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: textContent }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Summarization failed')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
setAiSummary(data.summary)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte sammanfatta texten',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSummarizing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
// Validate file type (only PDF for briefings)
|
||||
if (file.type !== 'application/pdf') {
|
||||
toast({
|
||||
title: 'Ogiltig filtyp',
|
||||
description: 'Endast PDF-filer ar tillåtna',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size (10MB max)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast({
|
||||
title: 'Filen ar for stor',
|
||||
description: 'Max filstorlek ar 10MB',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedFile(file)
|
||||
// Auto-fill title from filename if empty
|
||||
if (!title) {
|
||||
setTitle(file.name.replace(/\.pdf$/i, ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearFileSelection = () => {
|
||||
setSelectedFile(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Validation
|
||||
if (!title.trim()) {
|
||||
toast({
|
||||
title: 'Titel saknas',
|
||||
description: 'Ange en titel for briefingen',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (briefingType === 'link' && !linkUrl.trim()) {
|
||||
toast({
|
||||
title: 'Lank saknas',
|
||||
description: 'Ange en URL for briefingen',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (briefingType === 'text' && !textContent.trim()) {
|
||||
toast({
|
||||
title: 'Text saknas',
|
||||
description: 'Ange textinnehall for briefingen',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (briefingType === 'pdf' && !selectedFile && !isEditing) {
|
||||
toast({
|
||||
title: 'Fil saknas',
|
||||
description: 'Valj en PDF-fil att ladda upp',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setUploadProgress(0)
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
// Update existing briefing
|
||||
const updateData: Record<string, unknown> = {
|
||||
title: title.trim(),
|
||||
notes: notes.trim() || null,
|
||||
}
|
||||
|
||||
if (briefingType === 'link') {
|
||||
updateData.content = linkUrl.trim()
|
||||
} else if (briefingType === 'text') {
|
||||
updateData.text_content = textContent.trim()
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/briefings/${briefing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updateData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Update failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Briefing uppdaterad',
|
||||
description: title,
|
||||
})
|
||||
} else if (briefingType === 'pdf' && selectedFile) {
|
||||
// Upload PDF file
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile)
|
||||
formData.append('title', title.trim())
|
||||
if (notes.trim()) formData.append('notes', notes.trim())
|
||||
|
||||
// Simulate progress
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => Math.min(prev + 10, 90))
|
||||
}, 200)
|
||||
|
||||
const response = await fetch(`/api/campaigns/${campaignId}/briefings/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
clearInterval(progressInterval)
|
||||
setUploadProgress(100)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Upload failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Briefing uppladdad',
|
||||
description: title,
|
||||
})
|
||||
} else {
|
||||
// Create link or text briefing
|
||||
const createData = {
|
||||
briefing_type: briefingType,
|
||||
title: title.trim(),
|
||||
content: briefingType === 'link' ? linkUrl.trim() : null,
|
||||
text_content: briefingType === 'text' ? textContent.trim() : null,
|
||||
notes: notes.trim() || null,
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/campaigns/${campaignId}/briefings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(createData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Create failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Briefing tillagd',
|
||||
description: title,
|
||||
})
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Något gick fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
setUploadProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeIcon = (type: BriefingType) => {
|
||||
switch (type) {
|
||||
case 'pdf':
|
||||
return <FileText className="h-4 w-4" />
|
||||
case 'link':
|
||||
return <Link2 className="h-4 w-4" />
|
||||
case 'text':
|
||||
return <AlignLeft className="h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Type selector - only show when creating new */}
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<Label>Typ av briefing</Label>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1.5">
|
||||
{(['link', 'text', 'pdf'] as BriefingType[]).map(type => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setBriefingType(type)}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 p-3 border rounded-lg transition-colors',
|
||||
briefingType === type
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-muted hover:border-primary/50 hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{getTypeIcon(type)}
|
||||
<span className="text-sm font-medium">{BRIEFING_TYPE_LABELS[type]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<Label htmlFor="title">Titel *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="T.ex. Kampanjbrief Q1 2025"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type-specific content */}
|
||||
{briefingType === 'link' && (
|
||||
<div>
|
||||
<Label htmlFor="link">URL *</Label>
|
||||
<Input
|
||||
id="link"
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={e => setLinkUrl(e.target.value)}
|
||||
placeholder="https://docs.google.com/... eller https://www.canva.com/..."
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Lankar till Google Docs, Canva, Dropbox, etc.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{briefingType === 'text' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="text">Textinnehall *</Label>
|
||||
<Textarea
|
||||
id="text"
|
||||
value={textContent}
|
||||
onChange={e => setTextContent(e.target.value)}
|
||||
placeholder="Klistra in text fran e-post eller annat..."
|
||||
rows={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSummarize}
|
||||
disabled={isSummarizing || !textContent.trim()}
|
||||
>
|
||||
{isSummarizing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
Sammanfattar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4 mr-1" />
|
||||
Sammanfatta med AI
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{aiSummary && (
|
||||
<div className="p-4 border rounded-lg bg-muted/30">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-sm font-medium">AI-sammanfattning</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAiSummary('')}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap text-muted-foreground">
|
||||
{aiSummary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{briefingType === 'pdf' && !isEditing && (
|
||||
<div>
|
||||
<Label>PDF-fil *</Label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="flex items-center gap-3 p-3 border rounded-lg bg-muted/50 mt-1.5">
|
||||
<FileText className="h-8 w-8 text-red-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{selectedFile.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={clearFileSelection}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full p-6 border-2 border-dashed rounded-lg hover:border-primary hover:bg-muted/50 transition-colors mt-1.5"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="font-medium">Klicka for att valja PDF-fil</p>
|
||||
<p className="text-sm text-muted-foreground">Max 10MB</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<Label htmlFor="notes">Anteckningar (valfritt)</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder="Egna anteckningar om briefingen..."
|
||||
rows={2}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Upload progress */}
|
||||
{isSubmitting && briefingType === 'pdf' && uploadProgress > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={uploadProgress} />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Laddar upp... {uploadProgress}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{isEditing ? 'Sparar...' : 'Laddar upp...'}
|
||||
</>
|
||||
) : isEditing ? (
|
||||
'Spara andringar'
|
||||
) : (
|
||||
'Lagg till briefing'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Briefing, BriefingType, BRIEFING_TYPE_LABELS } from '@/types'
|
||||
import { BriefingForm } from './BriefingForm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
FileText,
|
||||
Link2,
|
||||
AlignLeft,
|
||||
Download,
|
||||
Trash2,
|
||||
Edit2,
|
||||
Plus,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface BriefingListProps {
|
||||
campaignId: string
|
||||
briefings: Briefing[]
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
export function BriefingList({ campaignId, briefings, onUpdate }: BriefingListProps) {
|
||||
const { toast } = useToast()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingBriefing, setEditingBriefing] = useState<Briefing | null>(null)
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||
const [expandedTextId, setExpandedTextId] = useState<string | null>(null)
|
||||
|
||||
const handleDownload = async (briefing: Briefing) => {
|
||||
if (briefing.briefing_type !== 'pdf') return
|
||||
|
||||
setDownloadingId(briefing.id)
|
||||
try {
|
||||
const response = await fetch(`/api/briefings/${briefing.id}/download`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Download failed')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
window.open(data.url, '_blank')
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte ladda ner filen',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setDownloadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (briefing: Briefing) => {
|
||||
if (!confirm(`Ta bort "${briefing.title}"?`)) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/briefings/${briefing.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Delete failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Briefing borttagen',
|
||||
description: briefing.title,
|
||||
})
|
||||
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte ta bort briefingen',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenLink = (briefing: Briefing) => {
|
||||
if (briefing.briefing_type !== 'link' || !briefing.content) return
|
||||
window.open(briefing.content, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const handleEdit = (briefing: Briefing) => {
|
||||
setEditingBriefing(briefing)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleFormClose = () => {
|
||||
setShowForm(false)
|
||||
setEditingBriefing(null)
|
||||
}
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
handleFormClose()
|
||||
onUpdate()
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number | null) => {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const getTypeIcon = (type: BriefingType) => {
|
||||
switch (type) {
|
||||
case 'pdf':
|
||||
return <FileText className="h-5 w-5 text-red-500" />
|
||||
case 'link':
|
||||
return <Link2 className="h-5 w-5 text-blue-500" />
|
||||
case 'text':
|
||||
return <AlignLeft className="h-5 w-5 text-green-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeBadgeColor = (type: BriefingType) => {
|
||||
switch (type) {
|
||||
case 'pdf':
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
case 'link':
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
case 'text':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
}
|
||||
}
|
||||
|
||||
const truncateUrl = (url: string, maxLength: number = 40) => {
|
||||
if (url.length <= maxLength) return url
|
||||
return url.substring(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
// Sort by created date (newest first)
|
||||
const sortedBriefings = [...briefings].sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">
|
||||
Briefing
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({briefings.length})
|
||||
</span>
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showForm ? 'secondary' : 'default'}
|
||||
onClick={() => {
|
||||
if (showForm) {
|
||||
handleFormClose()
|
||||
} else {
|
||||
setShowForm(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showForm ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
Stang
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lagg till
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form section */}
|
||||
{showForm && (
|
||||
<div className="p-4 border rounded-lg bg-muted/30">
|
||||
<BriefingForm
|
||||
campaignId={campaignId}
|
||||
briefing={editingBriefing}
|
||||
onSuccess={handleFormSuccess}
|
||||
onCancel={handleFormClose}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Briefing list */}
|
||||
{sortedBriefings.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{sortedBriefings.map(briefing => (
|
||||
<div
|
||||
key={briefing.id}
|
||||
className="p-3 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{getTypeIcon(briefing.briefing_type)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium">{briefing.title}</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('text-xs', getTypeBadgeColor(briefing.briefing_type))}
|
||||
>
|
||||
{BRIEFING_TYPE_LABELS[briefing.briefing_type]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Type-specific content display */}
|
||||
{briefing.briefing_type === 'pdf' && briefing.filename && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{briefing.filename}
|
||||
{briefing.file_size && (
|
||||
<span className="ml-2">({formatFileSize(briefing.file_size)})</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{briefing.briefing_type === 'link' && briefing.content && (
|
||||
<button
|
||||
onClick={() => handleOpenLink(briefing)}
|
||||
className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1"
|
||||
>
|
||||
{truncateUrl(briefing.content)}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{briefing.briefing_type === 'text' && briefing.text_content && (
|
||||
<div className="mt-2">
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground bg-muted/50 rounded p-2 whitespace-pre-wrap',
|
||||
expandedTextId !== briefing.id && 'max-h-20 overflow-hidden'
|
||||
)}
|
||||
>
|
||||
{briefing.text_content}
|
||||
</div>
|
||||
{briefing.text_content.length > 200 && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedTextId(
|
||||
expandedTextId === briefing.id ? null : briefing.id
|
||||
)
|
||||
}
|
||||
className="text-xs text-primary hover:underline mt-1 flex items-center gap-1"
|
||||
>
|
||||
{expandedTextId === briefing.id ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
Visa mindre
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
Visa mer
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{briefing.notes && (
|
||||
<p className="text-sm text-muted-foreground italic mt-1">
|
||||
{briefing.notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Tillagd: {formatDate(briefing.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{briefing.briefing_type === 'pdf' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(briefing)}
|
||||
disabled={downloadingId === briefing.id}
|
||||
title="Ladda ner"
|
||||
>
|
||||
{downloadingId === briefing.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{briefing.briefing_type === 'link' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenLink(briefing)}
|
||||
title="Oppna lank"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(briefing)}
|
||||
title="Redigera"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(briefing)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
title="Ta bort"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !showForm ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Ingen briefing tillagd</p>
|
||||
<p className="text-sm mt-1">
|
||||
Lagg till PDF-dokument, lankar eller text fran uppdragsgivaren
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lagg till briefing
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Campaign, CAMPAIGN_TYPE_LABELS } from '@/types'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { CampaignStatusBadge } from './CampaignStatusBadge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Building2,
|
||||
Calendar,
|
||||
FileText,
|
||||
Package,
|
||||
ChevronRight,
|
||||
Banknote,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface CampaignCardProps {
|
||||
campaign: Campaign
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function CampaignCard({ campaign, onClick }: CampaignCardProps) {
|
||||
const formatCurrency = (amount: number | null) => {
|
||||
if (!amount) return '-'
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: campaign.currency || 'SEK',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
// Deliverable progress
|
||||
const totalDeliverables = campaign.deliverables?.length || 0
|
||||
const completedDeliverables = campaign.deliverables?.filter(d =>
|
||||
['approved', 'published'].includes(d.status)
|
||||
).length || 0
|
||||
const pendingDeliverables = totalDeliverables - completedDeliverables
|
||||
const progressPercent = totalDeliverables > 0 ? Math.round((completedDeliverables / totalDeliverables) * 100) : 0
|
||||
|
||||
// Find next due date
|
||||
const nextDueDate = campaign.deliverables
|
||||
?.filter(d => d.due_date && !['approved', 'published'].includes(d.status))
|
||||
.sort((a, b) => new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime())[0]?.due_date
|
||||
|
||||
// Check if next deadline is soon (within 3 days)
|
||||
const isDeadlineSoon = nextDueDate && (() => {
|
||||
const daysUntil = Math.round((new Date(nextDueDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
return daysUntil <= 3 && daysUntil >= 0
|
||||
})()
|
||||
|
||||
// Check for active exclusivities
|
||||
const hasActiveExclusivity = campaign.exclusivities?.some(e => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return e.start_date <= today && e.end_date >= today
|
||||
})
|
||||
|
||||
// Customer initial for avatar
|
||||
const customerInitial = campaign.customer?.name?.charAt(0)?.toUpperCase() || '?'
|
||||
|
||||
return (
|
||||
<Link href={`/campaigns/${campaign.id}`}>
|
||||
<Card
|
||||
className={cn(
|
||||
'hover-lift cursor-pointer',
|
||||
campaign.status === 'cancelled' && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
{/* Customer avatar */}
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary">
|
||||
{customerInitial}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-lg truncate">{campaign.name || campaign.customer?.name || 'Namnlöst samarbete'}</CardTitle>
|
||||
{campaign.customer && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5 truncate">
|
||||
{campaign.customer.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<CampaignStatusBadge status={campaign.status} />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{CAMPAIGN_TYPE_LABELS[campaign.campaign_type]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{/* Deliverable progress bar */}
|
||||
{totalDeliverables > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-muted-foreground">Innehåll</span>
|
||||
<span className="font-medium">{completedDeliverables}/{totalDeliverables} klara</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{/* Value */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Banknote className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatCurrency(campaign.total_value)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
{campaign.start_date ? formatDate(campaign.start_date) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Next deadline - more prominent if soon */}
|
||||
{nextDueDate && (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2",
|
||||
isDeadlineSoon ? "text-warning-foreground" : "text-muted-foreground"
|
||||
)}>
|
||||
{isDeadlineSoon ? (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4" />
|
||||
)}
|
||||
<span className={isDeadlineSoon ? 'font-medium' : ''}>
|
||||
{formatDate(nextDueDate)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contracts */}
|
||||
{campaign.contracts && campaign.contracts.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>{campaign.contracts.length} avtal</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exclusivity indicator */}
|
||||
{hasActiveExclusivity && (
|
||||
<Badge variant="outline" className="text-xs w-fit">
|
||||
Aktiv exklusivitet
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,514 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Campaign,
|
||||
CAMPAIGN_STATUS_LABELS,
|
||||
CAMPAIGN_TYPE_LABELS,
|
||||
BILLING_FREQUENCY_LABELS,
|
||||
CampaignStatus
|
||||
} from '@/types'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { CampaignStatusBadge } from './CampaignStatusBadge'
|
||||
import { DeliverableList } from './DeliverableList'
|
||||
import { ExclusivityList } from './ExclusivityList'
|
||||
import { ContractList } from './ContractList'
|
||||
import { BriefingList } from './BriefingList'
|
||||
import { CampaignInvoiceSummary } from './CampaignInvoiceSummary'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Building2,
|
||||
Calendar,
|
||||
Banknote,
|
||||
Edit2,
|
||||
Trash2,
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileText,
|
||||
Package,
|
||||
Shield,
|
||||
Receipt,
|
||||
BookOpen,
|
||||
Check,
|
||||
Circle,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface CampaignDetailProps {
|
||||
campaign: Campaign
|
||||
onUpdate: () => void
|
||||
onEdit: () => void
|
||||
}
|
||||
|
||||
const STATUS_TRANSITIONS: Record<CampaignStatus, CampaignStatus[]> = {
|
||||
negotiation: ['contracted', 'cancelled'],
|
||||
contracted: ['active', 'cancelled'],
|
||||
active: ['delivered', 'cancelled'],
|
||||
delivered: ['invoiced', 'active'],
|
||||
invoiced: ['completed', 'delivered'],
|
||||
completed: ['invoiced'],
|
||||
cancelled: ['negotiation'],
|
||||
}
|
||||
|
||||
const LIFECYCLE_STEPS: { status: CampaignStatus; label: string }[] = [
|
||||
{ status: 'negotiation', label: 'Förhandling' },
|
||||
{ status: 'contracted', label: 'Avtalat' },
|
||||
{ status: 'active', label: 'Aktivt' },
|
||||
{ status: 'delivered', label: 'Levererat' },
|
||||
{ status: 'invoiced', label: 'Fakturerat' },
|
||||
{ status: 'completed', label: 'Klart' },
|
||||
]
|
||||
|
||||
function getStepState(stepStatus: CampaignStatus, currentStatus: CampaignStatus): 'completed' | 'current' | 'upcoming' {
|
||||
const stepIndex = LIFECYCLE_STEPS.findIndex(s => s.status === stepStatus)
|
||||
const currentIndex = LIFECYCLE_STEPS.findIndex(s => s.status === currentStatus)
|
||||
if (currentStatus === 'cancelled') return 'upcoming'
|
||||
if (stepIndex < currentIndex) return 'completed'
|
||||
if (stepIndex === currentIndex) return 'current'
|
||||
return 'upcoming'
|
||||
}
|
||||
|
||||
export function CampaignDetail({ campaign, onUpdate, onEdit }: CampaignDetailProps) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
|
||||
const formatCurrency = (amount: number | null) => {
|
||||
if (!amount) return '-'
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: campaign.currency || 'SEK',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const handleStatusChange = async (newStatus: CampaignStatus) => {
|
||||
setIsUpdating(true)
|
||||
try {
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update status')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Status uppdaterad',
|
||||
description: CAMPAIGN_STATUS_LABELS[newStatus],
|
||||
})
|
||||
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte uppdatera status',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Ta bort samarbetet "${campaign.name}"? Detta kan inte ångras.`)) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Samarbete borttaget',
|
||||
description: campaign.name,
|
||||
})
|
||||
|
||||
router.push('/campaigns')
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte ta bort samarbetet',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const availableTransitions = STATUS_TRANSITIONS[campaign.status] || []
|
||||
|
||||
// Count items
|
||||
const deliverableCount = campaign.deliverables?.length || 0
|
||||
const exclusivityCount = campaign.exclusivities?.length || 0
|
||||
const contractCount = campaign.contracts?.length || 0
|
||||
const invoiceCount = campaign.invoices?.length || 0
|
||||
const briefingCount = campaign.briefings?.length || 0
|
||||
|
||||
// Deliverable progress
|
||||
const completedDeliverables = campaign.deliverables?.filter(d =>
|
||||
['approved', 'published'].includes(d.status)
|
||||
).length || 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<Link
|
||||
href="/campaigns"
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Tillbaka till samarbeten
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{campaign.name}</h1>
|
||||
{(campaign.customer || campaign.brand_name) && (
|
||||
<p className="text-muted-foreground flex items-center gap-1 mt-1">
|
||||
{campaign.brand_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
{campaign.brand_name}
|
||||
</span>
|
||||
)}
|
||||
{campaign.brand_name && campaign.customer && (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{campaign.customer && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{campaign.customer.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Prominent "Create invoice" button when status is delivered */}
|
||||
{campaign.status === 'delivered' && (
|
||||
<Button
|
||||
onClick={() => router.push(`/invoices/new?campaign_id=${campaign.id}&customer_id=${campaign.customer_id}`)}
|
||||
className="bg-accent hover:bg-accent/90 text-accent-foreground"
|
||||
>
|
||||
<Receipt className="h-4 w-4 mr-1" />
|
||||
Skapa faktura
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<Edit2 className="h-4 w-4 mr-1" />
|
||||
Redigera
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Ta bort
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and quick info */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<CampaignStatusBadge status={campaign.status} />
|
||||
<Badge variant="secondary">{CAMPAIGN_TYPE_LABELS[campaign.campaign_type]}</Badge>
|
||||
|
||||
{availableTransitions.length > 0 && (
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(v) => handleStatusChange(v as CampaignStatus)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8 text-sm">
|
||||
<SelectValue placeholder="Ändra status..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTransitions.map(status => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{CAMPAIGN_STATUS_LABELS[status]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Campaign lifecycle timeline */}
|
||||
{campaign.status !== 'cancelled' && (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{LIFECYCLE_STEPS.map((step, index) => {
|
||||
const state = getStepState(step.status, campaign.status)
|
||||
return (
|
||||
<div key={step.status} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium transition-colors",
|
||||
state === 'completed' && "bg-success text-success-foreground",
|
||||
state === 'current' && "bg-primary text-primary-foreground ring-4 ring-primary/20",
|
||||
state === 'upcoming' && "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{state === 'completed' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Circle className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-[10px] mt-1.5 text-center leading-tight whitespace-nowrap",
|
||||
state === 'current' ? "text-foreground font-medium" : "text-muted-foreground"
|
||||
)}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < LIFECYCLE_STEPS.length - 1 && (
|
||||
<div className={cn(
|
||||
"flex-1 h-0.5 mx-1",
|
||||
state === 'completed' ? "bg-success" : "bg-muted"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Overview cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Banknote className="h-4 w-4" />
|
||||
<span className="text-sm">Arvode</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums">{formatCurrency(campaign.total_value)}</p>
|
||||
{campaign.vat_included && (
|
||||
<p className="text-xs text-muted-foreground">inkl. moms</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">Publiceringsdatum</span>
|
||||
</div>
|
||||
<p className="font-medium">
|
||||
{campaign.publication_date ? formatDate(campaign.publication_date) : 'Ej satt'}
|
||||
</p>
|
||||
{campaign.draft_deadline && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Utkast: {formatDate(campaign.draft_deadline)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Package className="h-4 w-4" />
|
||||
<span className="text-sm">Innehåll</span>
|
||||
</div>
|
||||
<p className="font-medium">
|
||||
{deliverableCount > 0 ? (
|
||||
<span>{completedDeliverables}/{deliverableCount} klara</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Inga</span>
|
||||
)}
|
||||
</p>
|
||||
{deliverableCount > 0 && (
|
||||
<div className="mt-2 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-success rounded-full transition-all"
|
||||
style={{ width: `${Math.round((completedDeliverables / deliverableCount) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover-lift">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">Avtal</span>
|
||||
</div>
|
||||
<p className="font-medium">
|
||||
{campaign.contract_signed_at
|
||||
? formatDate(campaign.contract_signed_at)
|
||||
: 'Ej signerat'}
|
||||
</p>
|
||||
{contractCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{contractCount} dokument
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{campaign.description && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<p className="text-muted-foreground whitespace-pre-wrap text-balance">
|
||||
{campaign.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs for different sections */}
|
||||
<Tabs defaultValue="deliverables" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="deliverables" className="gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Innehåll
|
||||
{deliverableCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">{deliverableCount}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="exclusivities" className="gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Exklusivitet
|
||||
{exclusivityCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">{exclusivityCount}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="briefings" className="gap-2">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Briefing
|
||||
{briefingCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">{briefingCount}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="contracts" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Avtal
|
||||
{contractCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">{contractCount}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="invoices" className="gap-2">
|
||||
<Receipt className="h-4 w-4" />
|
||||
Fakturor
|
||||
{invoiceCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">{invoiceCount}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="deliverables">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<DeliverableList
|
||||
campaignId={campaign.id}
|
||||
deliverables={campaign.deliverables || []}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="exclusivities">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<ExclusivityList
|
||||
campaignId={campaign.id}
|
||||
exclusivities={campaign.exclusivities || []}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="briefings">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<BriefingList
|
||||
campaignId={campaign.id}
|
||||
briefings={campaign.briefings || []}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contracts">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<ContractList
|
||||
campaignId={campaign.id}
|
||||
contracts={campaign.contracts || []}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="invoices">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CampaignInvoiceSummary
|
||||
campaignId={campaign.id}
|
||||
invoices={campaign.invoices || []}
|
||||
totalValue={campaign.total_value}
|
||||
currency={campaign.currency}
|
||||
onCreateInvoice={() => {
|
||||
router.push(`/invoices/new?campaign_id=${campaign.id}&customer_id=${campaign.customer_id}`)
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Notes */}
|
||||
{campaign.notes && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Anteckningar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{campaign.notes}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import type {
|
||||
Campaign,
|
||||
Customer,
|
||||
CampaignType,
|
||||
BillingFrequency,
|
||||
CreateCampaignInput
|
||||
} from '@/types'
|
||||
import {
|
||||
CAMPAIGN_TYPE_LABELS,
|
||||
BILLING_FREQUENCY_LABELS,
|
||||
} from '@/types'
|
||||
|
||||
interface CampaignFormProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
initialData?: Partial<Campaign>
|
||||
customers: Customer[]
|
||||
onSuccess?: (campaign: Campaign) => void
|
||||
}
|
||||
|
||||
const CURRENCIES = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
|
||||
export function CampaignForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData,
|
||||
customers,
|
||||
onSuccess
|
||||
}: CampaignFormProps) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [formData, setFormData] = useState<CreateCampaignInput>({
|
||||
name: '',
|
||||
description: '',
|
||||
customer_id: '',
|
||||
brand_name: '',
|
||||
campaign_type: 'influencer',
|
||||
total_value: undefined,
|
||||
currency: 'SEK',
|
||||
vat_included: false,
|
||||
payment_terms: 30,
|
||||
billing_frequency: undefined,
|
||||
publication_date: '',
|
||||
draft_deadline: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData({
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
customer_id: initialData?.customer_id || '',
|
||||
brand_name: initialData?.brand_name || '',
|
||||
campaign_type: initialData?.campaign_type || 'influencer',
|
||||
total_value: initialData?.total_value || undefined,
|
||||
currency: initialData?.currency || 'SEK',
|
||||
vat_included: initialData?.vat_included || false,
|
||||
payment_terms: initialData?.payment_terms || 30,
|
||||
billing_frequency: initialData?.billing_frequency || undefined,
|
||||
publication_date: initialData?.publication_date || '',
|
||||
draft_deadline: initialData?.draft_deadline || '',
|
||||
notes: initialData?.notes || '',
|
||||
})
|
||||
}
|
||||
}, [open, initialData])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formData.name) {
|
||||
toast({ title: 'Namn krävs', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const url = initialData?.id
|
||||
? `/api/campaigns/${initialData.id}`
|
||||
: '/api/campaigns'
|
||||
const method = initialData?.id ? 'PATCH' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
customer_id: formData.customer_id || null,
|
||||
brand_name: formData.brand_name || null,
|
||||
total_value: formData.total_value || null,
|
||||
payment_terms: formData.payment_terms || null,
|
||||
billing_frequency: formData.billing_frequency || null,
|
||||
publication_date: formData.publication_date || null,
|
||||
draft_deadline: formData.draft_deadline || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to save campaign')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
|
||||
toast({
|
||||
title: initialData?.id ? 'Samarbete uppdaterat' : 'Samarbete skapat',
|
||||
description: formData.name,
|
||||
})
|
||||
|
||||
onOpenChange(false)
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(data)
|
||||
} else if (!initialData?.id) {
|
||||
router.push(`/campaigns/${data.id}`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Något gick fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{initialData?.id ? 'Redigera samarbete' : 'Nytt samarbete'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Namn på samarbete *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="T.ex. Sommarkampanj 2025"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Beskrivning</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Kort beskrivning av kampanjen..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="brand_name">Varumärke</Label>
|
||||
<Input
|
||||
id="brand_name"
|
||||
value={formData.brand_name || ''}
|
||||
onChange={(e) => setFormData({ ...formData, brand_name: e.target.value })}
|
||||
placeholder="Varumärket du skapar innehåll för"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Varumärket som samarbetet gäller
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="campaign_type">Typ</Label>
|
||||
<Select
|
||||
value={formData.campaign_type}
|
||||
onValueChange={(v) => setFormData({ ...formData, campaign_type: v as CampaignType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj typ" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(CAMPAIGN_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="customer_id">Byrå / Uppdragsgivare</Label>
|
||||
<Select
|
||||
value={formData.customer_id || ''}
|
||||
onValueChange={(v) => setFormData({
|
||||
...formData,
|
||||
customer_id: v,
|
||||
})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj byrå/uppdragsgivare" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((customer) => (
|
||||
<SelectItem key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Den som faktureras (byrå eller direktkund)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Financial */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-medium">Ekonomi</h4>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="total_value">Arvode</Label>
|
||||
<Input
|
||||
id="total_value"
|
||||
type="number"
|
||||
value={formData.total_value || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
total_value: e.target.value ? parseFloat(e.target.value) : undefined
|
||||
})}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="currency">Valuta</Label>
|
||||
<Select
|
||||
value={formData.currency}
|
||||
onValueChange={(v) => setFormData({ ...formData, currency: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CURRENCIES.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="vat_included"
|
||||
checked={formData.vat_included}
|
||||
onCheckedChange={(v) => setFormData({ ...formData, vat_included: v })}
|
||||
/>
|
||||
<Label htmlFor="vat_included" className="font-normal">Inkl. moms</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="payment_terms">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="payment_terms"
|
||||
type="number"
|
||||
value={formData.payment_terms || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
payment_terms: e.target.value ? parseInt(e.target.value) : undefined
|
||||
})}
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="billing_frequency">Faktureringsmodell</Label>
|
||||
<Select
|
||||
value={formData.billing_frequency || ''}
|
||||
onValueChange={(v) => setFormData({
|
||||
...formData,
|
||||
billing_frequency: v as BillingFrequency || undefined
|
||||
})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(BILLING_FREQUENCY_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="font-medium">Datum</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="publication_date">Publiceringsdatum</Label>
|
||||
<Input
|
||||
id="publication_date"
|
||||
type="date"
|
||||
value={formData.publication_date || ''}
|
||||
onChange={(e) => setFormData({ ...formData, publication_date: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
När innehållet ska publiceras
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="draft_deadline">Utkastdeadline</Label>
|
||||
<Input
|
||||
id="draft_deadline"
|
||||
type="date"
|
||||
value={formData.draft_deadline || ''}
|
||||
onChange={(e) => setFormData({ ...formData, draft_deadline: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
När utkast ska skickas (valfritt)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="pt-4 border-t">
|
||||
<Label htmlFor="notes">Anteckningar</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={formData.notes || ''}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Interna anteckningar..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Skapa samarbete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Invoice, PAYMENT_STATUS_LABELS } from '@/types'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertTriangle
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CampaignInvoiceSummaryProps {
|
||||
campaignId: string
|
||||
invoices: Invoice[]
|
||||
totalValue: number | null
|
||||
currency: string
|
||||
onCreateInvoice?: () => void
|
||||
}
|
||||
|
||||
export function CampaignInvoiceSummary({
|
||||
campaignId,
|
||||
invoices,
|
||||
totalValue,
|
||||
currency,
|
||||
onCreateInvoice
|
||||
}: CampaignInvoiceSummaryProps) {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: currency || 'SEK',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const totalInvoiced = invoices.reduce((sum, inv) => sum + inv.total, 0)
|
||||
const totalPaid = invoices
|
||||
.filter(inv => inv.status === 'paid')
|
||||
.reduce((sum, inv) => sum + inv.total, 0)
|
||||
const invoicedPercentage = totalValue ? (totalInvoiced / totalValue) * 100 : 0
|
||||
const paidPercentage = totalValue ? (totalPaid / totalValue) * 100 : 0
|
||||
|
||||
// Group by status
|
||||
const paidInvoices = invoices.filter(inv => inv.status === 'paid')
|
||||
const sentInvoices = invoices.filter(inv => inv.status === 'sent')
|
||||
const overdueInvoices = invoices.filter(inv => inv.status === 'overdue')
|
||||
const draftInvoices = invoices.filter(inv => inv.status === 'draft')
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return <CheckCircle className="h-4 w-4 text-success" />
|
||||
case 'overdue':
|
||||
return <AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-muted-foreground" />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return <Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">Betald</Badge>
|
||||
case 'sent':
|
||||
return <Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-200">Skickad</Badge>
|
||||
case 'overdue':
|
||||
return <Badge variant="destructive">Förfallen</Badge>
|
||||
case 'draft':
|
||||
return <Badge variant="secondary">Utkast</Badge>
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">
|
||||
Fakturor
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({invoices.length})
|
||||
</span>
|
||||
</h3>
|
||||
{onCreateInvoice && (
|
||||
<Button size="sm" onClick={onCreateInvoice}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Skapa faktura
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress summary */}
|
||||
{totalValue && totalValue > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Fakturerat</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(totalInvoiced)} / {formatCurrency(totalValue)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={invoicedPercentage} className="h-2" />
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Betalt</span>
|
||||
<span className="font-medium text-success">
|
||||
{formatCurrency(totalPaid)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={paidPercentage} className="h-2 bg-muted [&>div]:bg-success" />
|
||||
|
||||
{totalValue > totalInvoiced && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kvar att fakturera: {formatCurrency(totalValue - totalInvoiced)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Invoice list */}
|
||||
{invoices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{invoices
|
||||
.sort((a, b) => new Date(b.invoice_date).getTime() - new Date(a.invoice_date).getTime())
|
||||
.map(invoice => (
|
||||
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 p-3 border rounded-lg hover:border-primary/50 transition-colors',
|
||||
invoice.status === 'overdue' && 'border-destructive/50 bg-destructive/5'
|
||||
)}>
|
||||
{getStatusIcon(invoice.status)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{invoice.invoice_number}</span>
|
||||
{getStatusBadge(invoice.status)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(invoice.invoice_date)}
|
||||
{invoice.due_date && ` • Förfaller: ${formatDate(invoice.due_date)}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatCurrency(invoice.total)}</p>
|
||||
{invoice.payment_status && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{PAYMENT_STATUS_LABELS[invoice.payment_status]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Inga fakturor ännu</p>
|
||||
{onCreateInvoice && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={onCreateInvoice}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Skapa första fakturan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Campaign, CampaignStatus, CampaignType, CAMPAIGN_STATUS_LABELS, CAMPAIGN_TYPE_LABELS } from '@/types'
|
||||
import { CampaignCard } from './CampaignCard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { EmptyCampaigns } from '@/components/ui/empty-state'
|
||||
|
||||
interface CampaignListProps {
|
||||
campaigns: Campaign[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: { value: CampaignStatus | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'Alla statusar' },
|
||||
{ value: 'negotiation', label: CAMPAIGN_STATUS_LABELS.negotiation },
|
||||
{ value: 'contracted', label: CAMPAIGN_STATUS_LABELS.contracted },
|
||||
{ value: 'active', label: CAMPAIGN_STATUS_LABELS.active },
|
||||
{ value: 'delivered', label: CAMPAIGN_STATUS_LABELS.delivered },
|
||||
{ value: 'invoiced', label: CAMPAIGN_STATUS_LABELS.invoiced },
|
||||
{ value: 'completed', label: CAMPAIGN_STATUS_LABELS.completed },
|
||||
{ value: 'cancelled', label: CAMPAIGN_STATUS_LABELS.cancelled },
|
||||
]
|
||||
|
||||
const TYPE_OPTIONS: { value: CampaignType | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'Alla typer' },
|
||||
{ value: 'influencer', label: CAMPAIGN_TYPE_LABELS.influencer },
|
||||
{ value: 'ugc', label: CAMPAIGN_TYPE_LABELS.ugc },
|
||||
{ value: 'ambassador', label: CAMPAIGN_TYPE_LABELS.ambassador },
|
||||
]
|
||||
|
||||
export function CampaignList({ campaigns, loading }: CampaignListProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<CampaignStatus | 'all'>('all')
|
||||
const [typeFilter, setTypeFilter] = useState<CampaignType | 'all'>('all')
|
||||
|
||||
// Filter campaigns
|
||||
const filteredCampaigns = campaigns.filter(campaign => {
|
||||
// Search filter
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
const matchesName = campaign.name.toLowerCase().includes(searchLower)
|
||||
const matchesCustomer = campaign.customer?.name.toLowerCase().includes(searchLower)
|
||||
const matchesDescription = campaign.description?.toLowerCase().includes(searchLower)
|
||||
if (!matchesName && !matchesCustomer && !matchesDescription) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter !== 'all' && campaign.status !== statusFilter) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (typeFilter !== 'all' && campaign.campaign_type !== typeFilter) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Group by status for active view
|
||||
const activeStatuses: CampaignStatus[] = ['active', 'contracted', 'negotiation', 'delivered', 'invoiced']
|
||||
const activeCampaigns = filteredCampaigns.filter(c => activeStatuses.includes(c.status))
|
||||
const completedCampaigns = filteredCampaigns.filter(c => c.status === 'completed')
|
||||
const cancelledCampaigns = filteredCampaigns.filter(c => c.status === 'cancelled')
|
||||
|
||||
const hasFilters = search || statusFilter !== 'all' || typeFilter !== 'all'
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('')
|
||||
setStatusFilter('all')
|
||||
setTypeFilter('all')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-40 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Sök samarbeten..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as CampaignStatus | 'all')}>
|
||||
<SelectTrigger className="w-full sm:w-[160px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as CampaignType | 'all')}>
|
||||
<SelectTrigger className="w-full sm:w-[160px]">
|
||||
<SelectValue placeholder="Typ" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPE_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="icon" onClick={clearFilters}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{filteredCampaigns.length === 0 ? (
|
||||
hasFilters ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">
|
||||
Inga samarbeten matchar filtren
|
||||
</p>
|
||||
<Button variant="outline" className="mt-4" onClick={clearFilters}>
|
||||
Rensa filter
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyCampaigns />
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Active campaigns */}
|
||||
{activeCampaigns.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Aktiva ({activeCampaigns.length})
|
||||
</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{activeCampaigns.map(campaign => (
|
||||
<CampaignCard key={campaign.id} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed campaigns */}
|
||||
{completedCampaigns.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Avslutade ({completedCampaigns.length})
|
||||
</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{completedCampaigns.map(campaign => (
|
||||
<CampaignCard key={campaign.id} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancelled campaigns */}
|
||||
{cancelledCampaigns.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Avbrutna ({cancelledCampaigns.length})
|
||||
</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{cancelledCampaigns.map(campaign => (
|
||||
<CampaignCard key={campaign.id} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { CampaignStatus } from '@/types'
|
||||
import { CAMPAIGN_STATUS_LABELS } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const STATUS_STYLES: Record<CampaignStatus, { className: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
|
||||
negotiation: { className: 'bg-blue-100 text-blue-700 border-blue-200', variant: 'outline' },
|
||||
contracted: { className: 'bg-purple-100 text-purple-700 border-purple-200', variant: 'outline' },
|
||||
active: { className: 'bg-green-100 text-green-700 border-green-200', variant: 'outline' },
|
||||
delivered: { className: 'bg-yellow-100 text-yellow-700 border-yellow-200', variant: 'outline' },
|
||||
invoiced: { className: 'bg-orange-100 text-orange-700 border-orange-200', variant: 'outline' },
|
||||
completed: { className: 'bg-emerald-100 text-emerald-700 border-emerald-200', variant: 'outline' },
|
||||
cancelled: { className: 'bg-gray-100 text-gray-500 border-gray-200', variant: 'outline' },
|
||||
}
|
||||
|
||||
interface CampaignStatusBadgeProps {
|
||||
status: CampaignStatus
|
||||
size?: 'sm' | 'default'
|
||||
}
|
||||
|
||||
export function CampaignStatusBadge({ status, size = 'default' }: CampaignStatusBadgeProps) {
|
||||
const style = STATUS_STYLES[status]
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={style.variant}
|
||||
className={cn(
|
||||
style.className,
|
||||
size === 'sm' && 'text-xs px-1.5 py-0'
|
||||
)}
|
||||
>
|
||||
{CAMPAIGN_STATUS_LABELS[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Campaign, Deliverable, CAMPAIGN_STATUS_LABELS } from '@/types'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CampaignStatusBadge } from './CampaignStatusBadge'
|
||||
import {
|
||||
Megaphone,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CampaignsWidgetProps {
|
||||
campaigns: Campaign[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CampaignsWidget({ campaigns, className }: CampaignsWidgetProps) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
// Get active campaigns
|
||||
const activeCampaigns = campaigns.filter(c =>
|
||||
['active', 'contracted', 'negotiation'].includes(c.status)
|
||||
)
|
||||
|
||||
// Find upcoming deliverables (next 7 days)
|
||||
const next7Days = new Date()
|
||||
next7Days.setDate(next7Days.getDate() + 7)
|
||||
const next7DaysStr = next7Days.toISOString().split('T')[0]
|
||||
|
||||
const upcomingDeliverables: (Deliverable & { campaignName: string })[] = []
|
||||
for (const campaign of activeCampaigns) {
|
||||
for (const deliverable of campaign.deliverables || []) {
|
||||
if (
|
||||
deliverable.due_date &&
|
||||
deliverable.due_date >= today &&
|
||||
deliverable.due_date <= next7DaysStr &&
|
||||
!['approved', 'published'].includes(deliverable.status)
|
||||
) {
|
||||
upcomingDeliverables.push({
|
||||
...deliverable,
|
||||
campaignName: campaign.name
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by due date
|
||||
upcomingDeliverables.sort((a, b) =>
|
||||
new Date(a.due_date!).getTime() - new Date(b.due_date!).getTime()
|
||||
)
|
||||
|
||||
// Find overdue deliverables
|
||||
const overdueCount = activeCampaigns.reduce((count, campaign) => {
|
||||
return count + (campaign.deliverables?.filter(d =>
|
||||
d.due_date &&
|
||||
d.due_date < today &&
|
||||
!['approved', 'published'].includes(d.status)
|
||||
).length || 0)
|
||||
}, 0)
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('sv-SE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
if (activeCampaigns.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Megaphone className="h-4 w-4" />
|
||||
Samarbeten
|
||||
</CardTitle>
|
||||
<Link href="/campaigns">
|
||||
<Button variant="ghost" size="sm" className="text-xs">
|
||||
Visa alla
|
||||
<ArrowRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Active campaigns summary */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Aktiva samarbeten</span>
|
||||
<span className="font-medium">{activeCampaigns.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Overdue warning */}
|
||||
{overdueCount > 0 && (
|
||||
<div className="flex items-center gap-2 p-2 bg-destructive/10 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
<span className="text-sm text-destructive">
|
||||
{overdueCount} försenat innehåll
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upcoming deliverables */}
|
||||
{upcomingDeliverables.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Kommande denna vecka
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{upcomingDeliverables.slice(0, 3).map(deliverable => (
|
||||
<div
|
||||
key={deliverable.id}
|
||||
className="flex items-center gap-2 p-2 rounded-lg border bg-card"
|
||||
>
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{deliverable.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{deliverable.campaignName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(deliverable.due_date!)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{upcomingDeliverables.length > 3 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
+{upcomingDeliverables.length - 3} till
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick add button */}
|
||||
<Link href="/campaigns/new" className="block">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Nytt samarbete
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Contract, ExtractionStatus } from '@/types'
|
||||
import { ContractUpload } from './ContractUpload'
|
||||
import { ExtractionStatusBadge } from '@/components/contracts/ExtractionStatusBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
Trash2,
|
||||
Star,
|
||||
Calendar,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ContractListProps {
|
||||
campaignId: string
|
||||
contracts: Contract[]
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
export function ContractList({ campaignId, contracts, onUpdate }: ContractListProps) {
|
||||
const { toast } = useToast()
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||
const [extractingId, setExtractingId] = useState<string | null>(null)
|
||||
|
||||
const handleDownload = async (contract: Contract) => {
|
||||
setDownloadingId(contract.id)
|
||||
try {
|
||||
const response = await fetch(`/api/contracts/${contract.id}/download`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Download failed')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Open download in new tab
|
||||
window.open(data.url, '_blank')
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte ladda ner filen',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setDownloadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (contract: Contract) => {
|
||||
if (!confirm(`Ta bort "${contract.filename}"?`)) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contracts/${contract.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Delete failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Avtal borttaget',
|
||||
description: contract.filename,
|
||||
})
|
||||
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte ta bort avtalet',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetPrimary = async (contract: Contract) => {
|
||||
try {
|
||||
const response = await fetch(`/api/contracts/${contract.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_primary: true }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Update failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Huvudavtal uppdaterat',
|
||||
})
|
||||
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte uppdatera',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtract = async (contract: Contract) => {
|
||||
if (contract.mime_type !== 'application/pdf') {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Endast PDF-filer kan analyseras',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setExtractingId(contract.id)
|
||||
try {
|
||||
const response = await fetch(`/api/contracts/${contract.id}/extract`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Extraction failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Analys klar',
|
||||
description: 'Avtalsinformationen har extraherats',
|
||||
})
|
||||
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel vid analys',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte analysera avtalet',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setExtractingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number | null) => {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const getFileIcon = (mimeType: string | null) => {
|
||||
if (mimeType?.startsWith('image/')) return '🖼️'
|
||||
if (mimeType === 'application/pdf') return '📄'
|
||||
if (mimeType?.includes('word')) return '📝'
|
||||
return '📎'
|
||||
}
|
||||
|
||||
// Sort: primary first, then by upload date
|
||||
const sortedContracts = [...contracts].sort((a, b) => {
|
||||
if (a.is_primary && !b.is_primary) return -1
|
||||
if (!a.is_primary && b.is_primary) return 1
|
||||
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime()
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">
|
||||
Avtal
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({contracts.length})
|
||||
</span>
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showUpload ? 'secondary' : 'default'}
|
||||
onClick={() => setShowUpload(!showUpload)}
|
||||
>
|
||||
{showUpload ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
Stäng
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Ladda upp
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Upload section */}
|
||||
{showUpload && (
|
||||
<div className="p-4 border rounded-lg bg-muted/30">
|
||||
<ContractUpload
|
||||
campaignId={campaignId}
|
||||
onSuccess={() => {
|
||||
setShowUpload(false)
|
||||
onUpdate()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contract list */}
|
||||
{sortedContracts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{sortedContracts.map(contract => (
|
||||
<div
|
||||
key={contract.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 border rounded-lg',
|
||||
contract.is_primary && 'border-primary/50 bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<div className="text-2xl">
|
||||
{getFileIcon(contract.mime_type)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium truncate">{contract.filename}</p>
|
||||
{contract.is_primary && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
<Star className="h-3 w-3 mr-1" />
|
||||
Huvudavtal
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>{formatFileSize(contract.file_size)}</span>
|
||||
{contract.signing_date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Signerat: {formatDate(contract.signing_date)}
|
||||
</span>
|
||||
)}
|
||||
<span>Uppladdat: {formatDate(contract.uploaded_at)}</span>
|
||||
{contract.extraction_status && contract.extraction_status !== 'pending' && (
|
||||
<ExtractionStatusBadge status={contract.extraction_status as ExtractionStatus} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{contract.mime_type === 'application/pdf' &&
|
||||
contract.extraction_status !== 'completed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleExtract(contract)}
|
||||
disabled={extractingId === contract.id}
|
||||
title="Analysera med AI"
|
||||
>
|
||||
{extractingId === contract.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!contract.is_primary && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSetPrimary(contract)}
|
||||
title="Sätt som huvudavtal"
|
||||
>
|
||||
<Star className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(contract)}
|
||||
disabled={downloadingId === contract.id}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(contract)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !showUpload ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Inga avtal uppladdade</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => setShowUpload(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Ladda upp avtal
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Upload, FileText, X } from 'lucide-react'
|
||||
|
||||
interface ContractUploadProps {
|
||||
campaignId: string
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function ContractUpload({ campaignId, onSuccess }: ContractUploadProps) {
|
||||
const { toast } = useToast()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [signingDate, setSigningDate] = useState('')
|
||||
const [isPrimary, setIsPrimary] = useState(true)
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
// Validate file type
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp'
|
||||
]
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast({
|
||||
title: 'Ogiltig filtyp',
|
||||
description: 'Tillåtna format: PDF, DOC, DOCX, JPG, PNG, WEBP',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size (10MB max)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast({
|
||||
title: 'Filen är för stor',
|
||||
description: 'Max filstorlek är 10MB',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile) return
|
||||
|
||||
setIsUploading(true)
|
||||
setUploadProgress(0)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile)
|
||||
if (signingDate) formData.append('signing_date', signingDate)
|
||||
formData.append('is_primary', isPrimary.toString())
|
||||
|
||||
// Simulate progress for better UX
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => Math.min(prev + 10, 90))
|
||||
}, 200)
|
||||
|
||||
const response = await fetch(`/api/campaigns/${campaignId}/contracts`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
clearInterval(progressInterval)
|
||||
setUploadProgress(100)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Upload failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Avtal uppladdat',
|
||||
description: selectedFile.name,
|
||||
})
|
||||
|
||||
// Reset form
|
||||
setSelectedFile(null)
|
||||
setSigningDate('')
|
||||
setIsPrimary(true)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Uppladdning misslyckades',
|
||||
description: error instanceof Error ? error.message : 'Något gick fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
setUploadProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedFile(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* File input */}
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.webp"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="flex items-center gap-3 p-3 border rounded-lg bg-muted/50">
|
||||
<FileText className="h-8 w-8 text-primary" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{selectedFile.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={clearSelection}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full p-8 border-2 border-dashed rounded-lg hover:border-primary hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="font-medium">Klicka för att välja fil</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PDF, DOC, DOCX, JPG, PNG (max 10MB)
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
{selectedFile && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="signing_date">Signeringsdatum</Label>
|
||||
<Input
|
||||
id="signing_date"
|
||||
type="date"
|
||||
value={signingDate}
|
||||
onChange={(e) => setSigningDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 pb-2">
|
||||
<Switch
|
||||
id="is_primary"
|
||||
checked={isPrimary}
|
||||
onCheckedChange={setIsPrimary}
|
||||
/>
|
||||
<Label htmlFor="is_primary" className="font-normal">
|
||||
Huvudavtal
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload progress */}
|
||||
{isUploading && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={uploadProgress} />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Laddar upp... {uploadProgress}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload button */}
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
className="w-full"
|
||||
>
|
||||
{isUploading ? 'Laddar upp...' : 'Ladda upp avtal'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Deliverable, DELIVERABLE_STATUS_LABELS, DELIVERABLE_TYPE_LABELS, PLATFORM_LABELS } from '@/types'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
Edit2,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
RotateCcw,
|
||||
Send
|
||||
} from 'lucide-react'
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
pending: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
in_progress: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
submitted: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
revision: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
approved: 'bg-green-100 text-green-700 border-green-200',
|
||||
published: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
}
|
||||
|
||||
interface DeliverableCardProps {
|
||||
deliverable: Deliverable
|
||||
onStatusChange?: (deliverable: Deliverable, newStatus: string) => void
|
||||
onEdit?: (deliverable: Deliverable) => void
|
||||
onDelete?: (deliverable: Deliverable) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function DeliverableCard({
|
||||
deliverable,
|
||||
onStatusChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
compact = false
|
||||
}: DeliverableCardProps) {
|
||||
const isCompleted = deliverable.status === 'approved' || deliverable.status === 'published'
|
||||
const isOverdue = deliverable.due_date && new Date(deliverable.due_date) < new Date() && !isCompleted
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return null
|
||||
return new Date(date).toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
// Next logical status based on current
|
||||
const getNextStatus = () => {
|
||||
switch (deliverable.status) {
|
||||
case 'pending': return 'in_progress'
|
||||
case 'in_progress': return 'submitted'
|
||||
case 'submitted': return 'approved'
|
||||
case 'revision': return 'submitted'
|
||||
case 'approved': return 'published'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
const nextStatus = getNextStatus()
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 rounded-lg border',
|
||||
isOverdue ? 'border-destructive/50 bg-destructive/5' : 'border-border'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => nextStatus && onStatusChange?.(deliverable, nextStatus)}
|
||||
className="flex-shrink-0"
|
||||
disabled={!onStatusChange || !nextStatus}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn(
|
||||
'text-sm font-medium truncate',
|
||||
isCompleted && 'line-through text-muted-foreground'
|
||||
)}>
|
||||
{deliverable.title}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{PLATFORM_LABELS[deliverable.platform]}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 rounded-lg border',
|
||||
isOverdue ? 'border-destructive/50 bg-destructive/5' : 'border-border'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
onClick={() => nextStatus && onStatusChange?.(deliverable, nextStatus)}
|
||||
className="mt-0.5 flex-shrink-0"
|
||||
disabled={!onStatusChange || !nextStatus}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
) : isOverdue ? (
|
||||
<Clock className="h-5 w-5 text-destructive" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className={cn(
|
||||
'font-medium',
|
||||
isCompleted && 'line-through text-muted-foreground'
|
||||
)}>
|
||||
{deliverable.title}
|
||||
{deliverable.quantity > 1 && (
|
||||
<span className="text-muted-foreground ml-1">×{deliverable.quantity}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
|
||||
<span>{PLATFORM_LABELS[deliverable.platform]}</span>
|
||||
<span>·</span>
|
||||
<span>{DELIVERABLE_TYPE_LABELS[deliverable.deliverable_type]}</span>
|
||||
{deliverable.account_handle && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{deliverable.account_handle}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{deliverable.due_date && (
|
||||
<p className={cn(
|
||||
'text-sm mt-1',
|
||||
isOverdue && !isCompleted ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}>
|
||||
Deadline: {formatDate(deliverable.due_date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs', STATUS_STYLES[deliverable.status])}
|
||||
>
|
||||
{DELIVERABLE_STATUS_LABELS[deliverable.status]}
|
||||
</Badge>
|
||||
{isOverdue && !isCompleted && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Försenad
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deliverable.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{deliverable.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
{(deliverable.submitted_at || deliverable.approved_at || deliverable.published_at) && (
|
||||
<div className="flex flex-wrap gap-3 mt-2 text-xs text-muted-foreground">
|
||||
{deliverable.submitted_at && (
|
||||
<span>Inskickat: {formatDate(deliverable.submitted_at)}</span>
|
||||
)}
|
||||
{deliverable.approved_at && (
|
||||
<span>Godkänt: {formatDate(deliverable.approved_at)}</span>
|
||||
)}
|
||||
{deliverable.published_at && (
|
||||
<span>Publicerat: {formatDate(deliverable.published_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(onStatusChange || onEdit || onDelete) && (
|
||||
<div className="flex justify-between items-center gap-1 mt-3 pt-3 border-t">
|
||||
{/* Status progression buttons */}
|
||||
<div className="flex gap-1">
|
||||
{onStatusChange && nextStatus && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onStatusChange(deliverable, nextStatus)}
|
||||
className="text-xs"
|
||||
>
|
||||
{nextStatus === 'in_progress' && <Clock className="h-3 w-3 mr-1" />}
|
||||
{nextStatus === 'submitted' && <Send className="h-3 w-3 mr-1" />}
|
||||
{nextStatus === 'approved' && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||
{nextStatus === 'published' && <ExternalLink className="h-3 w-3 mr-1" />}
|
||||
{DELIVERABLE_STATUS_LABELS[nextStatus as keyof typeof DELIVERABLE_STATUS_LABELS]}
|
||||
</Button>
|
||||
)}
|
||||
{onStatusChange && deliverable.status === 'submitted' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onStatusChange(deliverable, 'revision')}
|
||||
className="text-xs text-orange-600"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Revision
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{onEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onEdit(deliverable)}>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(deliverable)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import type { Deliverable, DeliverableType, PlatformType, CreateDeliverableInput } from '@/types'
|
||||
import { DELIVERABLE_TYPE_LABELS, PLATFORM_LABELS } from '@/types'
|
||||
|
||||
interface DeliverableFormProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
campaignId: string
|
||||
initialData?: Partial<Deliverable>
|
||||
onSuccess?: (deliverable: Deliverable) => void
|
||||
}
|
||||
|
||||
export function DeliverableForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
campaignId,
|
||||
initialData,
|
||||
onSuccess
|
||||
}: DeliverableFormProps) {
|
||||
const { toast } = useToast()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [formData, setFormData] = useState<Omit<CreateDeliverableInput, 'campaign_id'>>({
|
||||
title: '',
|
||||
deliverable_type: 'video',
|
||||
platform: 'instagram',
|
||||
account_handle: '',
|
||||
quantity: 1,
|
||||
description: '',
|
||||
due_date: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData({
|
||||
title: initialData?.title || '',
|
||||
deliverable_type: initialData?.deliverable_type || 'video',
|
||||
platform: initialData?.platform || 'instagram',
|
||||
account_handle: initialData?.account_handle || '',
|
||||
quantity: initialData?.quantity || 1,
|
||||
description: initialData?.description || '',
|
||||
due_date: initialData?.due_date || '',
|
||||
notes: initialData?.notes || '',
|
||||
})
|
||||
}
|
||||
}, [open, initialData])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.title) {
|
||||
toast({ title: 'Titel krävs', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const url = initialData?.id
|
||||
? `/api/deliverables/${initialData.id}`
|
||||
: `/api/campaigns/${campaignId}/deliverables`
|
||||
const method = initialData?.id ? 'PATCH' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
due_date: formData.due_date || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to save deliverable')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
|
||||
toast({
|
||||
title: initialData?.id ? 'Leverabel uppdaterad' : 'Leverabel tillagd',
|
||||
description: formData.title,
|
||||
})
|
||||
|
||||
onOpenChange(false)
|
||||
onSuccess?.(data)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Något gick fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{initialData?.id ? 'Redigera leverabel' : 'Lägg till leverabel'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Titel *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="T.ex. Instagram Reel - Produktrecension"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="platform">Plattform *</Label>
|
||||
<Select
|
||||
value={formData.platform}
|
||||
onValueChange={(v) => setFormData({ ...formData, platform: v as PlatformType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PLATFORM_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="deliverable_type">Typ *</Label>
|
||||
<Select
|
||||
value={formData.deliverable_type}
|
||||
onValueChange={(v) => setFormData({ ...formData, deliverable_type: v as DeliverableType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(DELIVERABLE_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="account_handle">Konto/Kanal</Label>
|
||||
<Input
|
||||
id="account_handle"
|
||||
value={formData.account_handle || ''}
|
||||
onChange={(e) => setFormData({ ...formData, account_handle: e.target.value })}
|
||||
placeholder="@användarnamn"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quantity">Antal</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.quantity || 1}
|
||||
onChange={(e) => setFormData({ ...formData, quantity: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="due_date">Deadline</Label>
|
||||
<Input
|
||||
id="due_date"
|
||||
type="date"
|
||||
value={formData.due_date || ''}
|
||||
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
En deadline skapas automatiskt i kalendern
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Beskrivning</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Specifika instruktioner eller krav..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="notes">Interna anteckningar</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={formData.notes || ''}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Anteckningar..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Lägg till'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Deliverable, DeliverableStatus, DELIVERABLE_STATUS_LABELS } from '@/types'
|
||||
import { DeliverableCard } from './DeliverableCard'
|
||||
import { DeliverableForm } from './DeliverableForm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DeliverableListProps {
|
||||
campaignId: string
|
||||
deliverables: Deliverable[]
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
export function DeliverableList({ campaignId, deliverables, onUpdate }: DeliverableListProps) {
|
||||
const { toast } = useToast()
|
||||
const [showCompleted, setShowCompleted] = useState(false)
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editingDeliverable, setEditingDeliverable] = useState<Deliverable | null>(null)
|
||||
|
||||
// Split deliverables by completion status
|
||||
const activeDeliverables = deliverables.filter(d =>
|
||||
!['approved', 'published'].includes(d.status)
|
||||
)
|
||||
const completedDeliverables = deliverables.filter(d =>
|
||||
['approved', 'published'].includes(d.status)
|
||||
)
|
||||
|
||||
const handleStatusChange = async (deliverable: Deliverable, newStatus: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/deliverables/${deliverable.id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update status')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Status uppdaterad',
|
||||
description: `${deliverable.title}: ${DELIVERABLE_STATUS_LABELS[newStatus as DeliverableStatus]}`,
|
||||
})
|
||||
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte uppdatera status',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (deliverable: Deliverable) => {
|
||||
if (!confirm(`Ta bort "${deliverable.title}"?`)) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/deliverables/${deliverable.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Innehåll borttaget',
|
||||
description: deliverable.title,
|
||||
})
|
||||
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte ta bort innehåll',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">
|
||||
Innehåll
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({activeDeliverables.length} aktiva)
|
||||
</span>
|
||||
</h3>
|
||||
<Button size="sm" onClick={() => setFormOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Active deliverables */}
|
||||
{activeDeliverables.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{activeDeliverables
|
||||
.sort((a, b) => {
|
||||
// Sort by due date, nulls last
|
||||
if (!a.due_date && !b.due_date) return 0
|
||||
if (!a.due_date) return 1
|
||||
if (!b.due_date) return -1
|
||||
return new Date(a.due_date).getTime() - new Date(b.due_date).getTime()
|
||||
})
|
||||
.map(deliverable => (
|
||||
<DeliverableCard
|
||||
key={deliverable.id}
|
||||
deliverable={deliverable}
|
||||
onStatusChange={handleStatusChange}
|
||||
onEdit={(d) => {
|
||||
setEditingDeliverable(d)
|
||||
setFormOpen(true)
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>Inget aktivt innehåll</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => setFormOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till innehåll
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed deliverables */}
|
||||
{completedDeliverables.length > 0 && (
|
||||
<div className="pt-4 border-t">
|
||||
<button
|
||||
onClick={() => setShowCompleted(!showCompleted)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showCompleted ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
Klara ({completedDeliverables.length})
|
||||
</button>
|
||||
|
||||
{showCompleted && (
|
||||
<div className="space-y-3 mt-3">
|
||||
{completedDeliverables.map(deliverable => (
|
||||
<DeliverableCard
|
||||
key={deliverable.id}
|
||||
deliverable={deliverable}
|
||||
onEdit={(d) => {
|
||||
setEditingDeliverable(d)
|
||||
setFormOpen(true)
|
||||
}}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form dialog */}
|
||||
<DeliverableForm
|
||||
open={formOpen}
|
||||
onOpenChange={(open) => {
|
||||
setFormOpen(open)
|
||||
if (!open) setEditingDeliverable(null)
|
||||
}}
|
||||
campaignId={campaignId}
|
||||
initialData={editingDeliverable || undefined}
|
||||
onSuccess={() => {
|
||||
setFormOpen(false)
|
||||
setEditingDeliverable(null)
|
||||
onUpdate()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import type { Exclusivity, CreateExclusivityInput, ExclusivityConflict } from '@/types'
|
||||
import { AlertTriangle, X, Plus } from 'lucide-react'
|
||||
|
||||
interface ExclusivityFormProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
campaignId: string
|
||||
initialData?: Partial<Exclusivity>
|
||||
onSuccess?: (exclusivity: Exclusivity) => void
|
||||
}
|
||||
|
||||
// Common category suggestions
|
||||
const CATEGORY_SUGGESTIONS = [
|
||||
'Skönhet', 'Hudvård', 'Smink', 'Mode', 'Kläder', 'Accessoarer',
|
||||
'Mat', 'Dryck', 'Kaffe', 'Energidryck', 'Snacks',
|
||||
'Teknik', 'Mobil', 'Gaming', 'Elektronik',
|
||||
'Träning', 'Gym', 'Kosttillskott', 'Sport',
|
||||
'Resor', 'Hotell', 'Flyg',
|
||||
'Bank', 'Finans', 'Försäkring',
|
||||
'Bil', 'Bilar', 'Transport'
|
||||
]
|
||||
|
||||
export function ExclusivityForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
campaignId,
|
||||
initialData,
|
||||
onSuccess
|
||||
}: ExclusivityFormProps) {
|
||||
const { toast } = useToast()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
|
||||
const [conflicts, setConflicts] = useState<ExclusivityConflict[]>([])
|
||||
const [newCategory, setNewCategory] = useState('')
|
||||
const [newBrand, setNewBrand] = useState('')
|
||||
|
||||
const [formData, setFormData] = useState<Omit<CreateExclusivityInput, 'campaign_id'>>({
|
||||
categories: [],
|
||||
excluded_brands: [],
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData({
|
||||
categories: initialData?.categories || [],
|
||||
excluded_brands: initialData?.excluded_brands || [],
|
||||
start_date: initialData?.start_date || '',
|
||||
end_date: initialData?.end_date || '',
|
||||
notes: initialData?.notes || '',
|
||||
})
|
||||
setConflicts([])
|
||||
setNewCategory('')
|
||||
setNewBrand('')
|
||||
}
|
||||
}, [open, initialData])
|
||||
|
||||
// Check for conflicts when dates or categories change
|
||||
const checkConflicts = async () => {
|
||||
if (!formData.categories.length || !formData.start_date || !formData.end_date) {
|
||||
setConflicts([])
|
||||
return
|
||||
}
|
||||
|
||||
setIsCheckingConflicts(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
categories: formData.categories.join(','),
|
||||
start_date: formData.start_date,
|
||||
end_date: formData.end_date,
|
||||
exclude_campaign_id: campaignId,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/exclusivities/conflicts?${params}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setConflicts(data.conflicts || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check conflicts:', error)
|
||||
} finally {
|
||||
setIsCheckingConflicts(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(checkConflicts, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [formData.categories, formData.start_date, formData.end_date])
|
||||
|
||||
const addCategory = (category: string) => {
|
||||
const trimmed = category.trim()
|
||||
if (trimmed && !formData.categories.includes(trimmed)) {
|
||||
setFormData({
|
||||
...formData,
|
||||
categories: [...formData.categories, trimmed]
|
||||
})
|
||||
}
|
||||
setNewCategory('')
|
||||
}
|
||||
|
||||
const removeCategory = (category: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
categories: formData.categories.filter(c => c !== category)
|
||||
})
|
||||
}
|
||||
|
||||
const addBrand = (brand: string) => {
|
||||
const trimmed = brand.trim()
|
||||
if (trimmed && !formData.excluded_brands?.includes(trimmed)) {
|
||||
setFormData({
|
||||
...formData,
|
||||
excluded_brands: [...(formData.excluded_brands || []), trimmed]
|
||||
})
|
||||
}
|
||||
setNewBrand('')
|
||||
}
|
||||
|
||||
const removeBrand = (brand: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
excluded_brands: formData.excluded_brands?.filter(b => b !== brand) || []
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (formData.categories.length === 0) {
|
||||
toast({ title: 'Minst en kategori krävs', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.start_date || !formData.end_date) {
|
||||
toast({ title: 'Start- och slutdatum krävs', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const url = initialData?.id
|
||||
? `/api/exclusivities/${initialData.id}`
|
||||
: `/api/campaigns/${campaignId}/exclusivities`
|
||||
const method = initialData?.id ? 'PATCH' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to save exclusivity')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Show warning if there are conflicts
|
||||
if (result.warning) {
|
||||
toast({
|
||||
title: 'Exklusivitet sparad med varning',
|
||||
description: result.warning,
|
||||
variant: 'default',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: initialData?.id ? 'Exklusivitet uppdaterad' : 'Exklusivitet tillagd',
|
||||
})
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
onSuccess?.(result.data)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Något gick fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{initialData?.id ? 'Redigera exklusivitet' : 'Lägg till exklusivitet'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Categories */}
|
||||
<div>
|
||||
<Label>Kategorier *</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-2 mb-2">
|
||||
{formData.categories.map(category => (
|
||||
<Badge key={category} variant="secondary" className="gap-1">
|
||||
{category}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCategory(category)}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newCategory}
|
||||
onChange={(e) => setNewCategory(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addCategory(newCategory)
|
||||
}
|
||||
}}
|
||||
placeholder="Lägg till kategori..."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => addCategory(newCategory)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{CATEGORY_SUGGESTIONS
|
||||
.filter(s => !formData.categories.includes(s))
|
||||
.slice(0, 8)
|
||||
.map(suggestion => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => addCategory(suggestion)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-0.5 rounded border hover:border-primary"
|
||||
>
|
||||
+ {suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Excluded brands */}
|
||||
<div>
|
||||
<Label>Specifika varumärken som exkluderas (valfritt)</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-2 mb-2">
|
||||
{formData.excluded_brands?.map(brand => (
|
||||
<Badge key={brand} variant="outline" className="gap-1">
|
||||
{brand}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeBrand(brand)}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newBrand}
|
||||
onChange={(e) => setNewBrand(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addBrand(newBrand)
|
||||
}
|
||||
}}
|
||||
placeholder="Lägg till varumärke..."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => addBrand(newBrand)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="start_date">Startdatum *</Label>
|
||||
<Input
|
||||
id="start_date"
|
||||
type="date"
|
||||
value={formData.start_date}
|
||||
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="end_date">Slutdatum *</Label>
|
||||
<Input
|
||||
id="end_date"
|
||||
type="date"
|
||||
value={formData.end_date}
|
||||
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflict warnings */}
|
||||
{conflicts.length > 0 && (
|
||||
<div className="p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-600 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-orange-800">
|
||||
{conflicts.length} överlappande exklusivitet(er)
|
||||
</p>
|
||||
<ul className="text-sm text-orange-700 mt-1 space-y-1">
|
||||
{conflicts.map((conflict, i) => (
|
||||
<li key={i}>
|
||||
<strong>{conflict.conflictingCampaign?.name || 'Okänd kampanj'}</strong>
|
||||
: {conflict.overlappingCategories.join(', ')}
|
||||
{' '}({conflict.overlapStart} - {conflict.overlapEnd})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<Label htmlFor="notes">Anteckningar</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={formData.notes || ''}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Detaljer om exklusiviteten..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Lägg till'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Exclusivity } from '@/types'
|
||||
import { ExclusivityForm } from './ExclusivityForm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Trash2, Edit2, Calendar, AlertTriangle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ExclusivityListProps {
|
||||
campaignId: string
|
||||
exclusivities: Exclusivity[]
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
export function ExclusivityList({ campaignId, exclusivities, onUpdate }: ExclusivityListProps) {
|
||||
const { toast } = useToast()
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editingExclusivity, setEditingExclusivity] = useState<Exclusivity | null>(null)
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
const handleDelete = async (exclusivity: Exclusivity) => {
|
||||
if (!confirm('Ta bort denna exklusivitet?')) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/exclusivities/${exclusivity.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Exklusivitet borttagen',
|
||||
})
|
||||
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte ta bort exklusivitet',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const getStatus = (exclusivity: Exclusivity) => {
|
||||
if (exclusivity.end_date < today) return 'expired'
|
||||
if (exclusivity.start_date > today) return 'upcoming'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">
|
||||
Exklusiviteter
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({exclusivities.length})
|
||||
</span>
|
||||
</h3>
|
||||
<Button size="sm" onClick={() => setFormOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{exclusivities.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{exclusivities
|
||||
.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime())
|
||||
.map(exclusivity => {
|
||||
const status = getStatus(exclusivity)
|
||||
return (
|
||||
<div
|
||||
key={exclusivity.id}
|
||||
className={cn(
|
||||
'p-4 rounded-lg border',
|
||||
status === 'active' && 'border-orange-200 bg-orange-50',
|
||||
status === 'upcoming' && 'border-blue-200 bg-blue-50',
|
||||
status === 'expired' && 'border-gray-200 bg-gray-50 opacity-60'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{status === 'active' && (
|
||||
<Badge variant="outline" className="text-orange-700 border-orange-300 bg-orange-100">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Aktiv
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'upcoming' && (
|
||||
<Badge variant="outline" className="text-blue-700 border-blue-300 bg-blue-100">
|
||||
Kommande
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'expired' && (
|
||||
<Badge variant="outline" className="text-gray-500">
|
||||
Avslutad
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{exclusivity.categories.map(category => (
|
||||
<Badge key={category} variant="secondary">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{exclusivity.excluded_brands && exclusivity.excluded_brands.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
Exkluderade varumärken: {exclusivity.excluded_brands.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
{formatDate(exclusivity.start_date)} - {formatDate(exclusivity.end_date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{exclusivity.notes && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{exclusivity.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingExclusivity(exclusivity)
|
||||
setFormOpen(true)
|
||||
}}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(exclusivity)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>Inga exklusiviteter</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => setFormOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till exklusivitet
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form dialog */}
|
||||
<ExclusivityForm
|
||||
open={formOpen}
|
||||
onOpenChange={(open) => {
|
||||
setFormOpen(open)
|
||||
if (!open) setEditingExclusivity(null)
|
||||
}}
|
||||
campaignId={campaignId}
|
||||
initialData={editingExclusivity || undefined}
|
||||
onSuccess={() => {
|
||||
setFormOpen(false)
|
||||
setEditingExclusivity(null)
|
||||
onUpdate()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export { CampaignStatusBadge } from './CampaignStatusBadge'
|
||||
export { CampaignCard } from './CampaignCard'
|
||||
export { CampaignList } from './CampaignList'
|
||||
export { CampaignForm } from './CampaignForm'
|
||||
export { CampaignDetail } from './CampaignDetail'
|
||||
export { DeliverableCard } from './DeliverableCard'
|
||||
export { DeliverableForm } from './DeliverableForm'
|
||||
export { DeliverableList } from './DeliverableList'
|
||||
export { ExclusivityForm } from './ExclusivityForm'
|
||||
export { ExclusivityList } from './ExclusivityList'
|
||||
export { ContractUpload } from './ContractUpload'
|
||||
export { ContractList } from './ContractList'
|
||||
export { CampaignInvoiceSummary } from './CampaignInvoiceSummary'
|
||||
export { CampaignsWidget } from './CampaignsWidget'
|
||||
@@ -86,20 +86,20 @@ export function ChatPanel({ className }: ChatPanelProps) {
|
||||
<h4 className="font-medium mb-2">Hur kan jag hjälpa dig?</h4>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Jag kan svara på frågor om skatt, moms, bokföring och andra
|
||||
ekonomiska frågor för influencers.
|
||||
ekonomiska frågor för företagare.
|
||||
</p>
|
||||
<div className="mt-6 space-y-2 w-full max-w-xs">
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Hur fungerar momsen för YouTube-intäkter?')}
|
||||
onClick={() => sendMessage('Hur fungerar momsen på mina fakturor?')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Hur fungerar momsen för YouTube-intäkter?
|
||||
Hur fungerar momsen på mina fakturor?
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Vad kan jag dra av som influencer?')}
|
||||
onClick={() => sendMessage('Vad kan jag dra av som företagare?')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Vad kan jag dra av som influencer?
|
||||
Vad kan jag dra av som företagare?
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('När måste jag momsregistrera mig?')}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ConfidenceLevel } from '@/types'
|
||||
import { CheckCircle2, AlertCircle, HelpCircle, XCircle } from 'lucide-react'
|
||||
|
||||
interface ConfidenceIndicatorProps {
|
||||
level: ConfidenceLevel
|
||||
showLabel?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
const config: Record<ConfidenceLevel, {
|
||||
icon: typeof CheckCircle2
|
||||
color: string
|
||||
bgColor: string
|
||||
label: string
|
||||
}> = {
|
||||
high: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
label: 'Hög säkerhet',
|
||||
},
|
||||
medium: {
|
||||
icon: AlertCircle,
|
||||
color: 'text-yellow-600',
|
||||
bgColor: 'bg-yellow-100',
|
||||
label: 'Medel säkerhet',
|
||||
},
|
||||
low: {
|
||||
icon: HelpCircle,
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-100',
|
||||
label: 'Låg säkerhet',
|
||||
},
|
||||
missing: {
|
||||
icon: XCircle,
|
||||
color: 'text-gray-400',
|
||||
bgColor: 'bg-gray-100',
|
||||
label: 'Saknas',
|
||||
},
|
||||
}
|
||||
|
||||
export function ConfidenceIndicator({
|
||||
level,
|
||||
showLabel = false,
|
||||
size = 'md',
|
||||
}: ConfidenceIndicatorProps) {
|
||||
const { icon: Icon, color, bgColor, label } = config[level]
|
||||
|
||||
const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'
|
||||
|
||||
if (showLabel) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
bgColor,
|
||||
color
|
||||
)}
|
||||
>
|
||||
<Icon className={iconSize} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span title={label}>
|
||||
<Icon className={cn(iconSize, color)} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface ConfidenceFieldProps {
|
||||
label: string
|
||||
confidence: ConfidenceLevel
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ConfidenceField({
|
||||
label,
|
||||
confidence,
|
||||
children,
|
||||
className,
|
||||
}: ConfidenceFieldProps) {
|
||||
return (
|
||||
<div className={cn('space-y-1', className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<ConfidenceIndicator level={confidence} size="sm" />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,68 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ExtractionStatus } from '@/types'
|
||||
import { Loader2, CheckCircle2, XCircle, Clock, SkipForward } from 'lucide-react'
|
||||
|
||||
interface ExtractionStatusBadgeProps {
|
||||
status: ExtractionStatus
|
||||
className?: string
|
||||
}
|
||||
|
||||
const statusConfig: Record<ExtractionStatus, {
|
||||
icon: typeof Loader2
|
||||
color: string
|
||||
bgColor: string
|
||||
label: string
|
||||
animate?: boolean
|
||||
}> = {
|
||||
pending: {
|
||||
icon: Clock,
|
||||
color: 'text-gray-600',
|
||||
bgColor: 'bg-gray-100',
|
||||
label: 'Väntar',
|
||||
},
|
||||
processing: {
|
||||
icon: Loader2,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100',
|
||||
label: 'Analyserar...',
|
||||
animate: true,
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
label: 'Klar',
|
||||
},
|
||||
failed: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-100',
|
||||
label: 'Misslyckades',
|
||||
},
|
||||
skipped: {
|
||||
icon: SkipForward,
|
||||
color: 'text-gray-500',
|
||||
bgColor: 'bg-gray-100',
|
||||
label: 'Hoppade över',
|
||||
},
|
||||
}
|
||||
|
||||
export function ExtractionStatusBadge({ status, className }: ExtractionStatusBadgeProps) {
|
||||
const { icon: Icon, color, bgColor, label, animate } = statusConfig[status]
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium',
|
||||
bgColor,
|
||||
color,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-3.5 w-3.5', animate && 'animate-spin')} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
import { getSchablonavdragSummary } from '@/lib/tax/schablonavdrag'
|
||||
import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard'
|
||||
import { UpcomingDeadlinesWidget } from '@/components/calendar/UpcomingDeadlinesWidget'
|
||||
import { CampaignsWidget } from '@/components/campaigns/CampaignsWidget'
|
||||
import { TikTokStatsWidget } from '@/components/tiktok'
|
||||
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
||||
import {
|
||||
TrendingUp,
|
||||
@@ -23,15 +21,13 @@ import {
|
||||
Receipt,
|
||||
ArrowLeftRight,
|
||||
ChevronDown,
|
||||
Gift,
|
||||
ArrowRight,
|
||||
Megaphone,
|
||||
Camera,
|
||||
HelpCircle,
|
||||
Users,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import type { CompanySettings, EntityType, MileageEntry, SchablonavdragSettings, GiftSummary, Deadline, Campaign, TikTokStatsSummary, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
import type { CompanySettings, EntityType, MileageEntry, SchablonavdragSettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
|
||||
interface DashboardContentProps {
|
||||
firstName?: string | null
|
||||
@@ -48,56 +44,15 @@ interface DashboardContentProps {
|
||||
overdueInvoicesCount: number
|
||||
bankBalance: number | null
|
||||
mileageEntries: MileageEntry[]
|
||||
giftSummary: GiftSummary | null
|
||||
deadlines: Deadline[]
|
||||
campaigns: Campaign[]
|
||||
receiptQueue: ReceiptQueueSummary | null
|
||||
}
|
||||
onboardingProgress?: OnboardingProgress
|
||||
}
|
||||
|
||||
export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) {
|
||||
const [tiktokStats, setTiktokStats] = useState<TikTokStatsSummary | null>(null)
|
||||
const [isTiktokSyncing, setIsTiktokSyncing] = useState(false)
|
||||
const [showAllAlerts, setShowAllAlerts] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchTikTokStats()
|
||||
}, [])
|
||||
|
||||
const fetchTikTokStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tiktok/stats')
|
||||
const data = await response.json()
|
||||
if (data.summary) {
|
||||
setTiktokStats(data.summary)
|
||||
}
|
||||
} catch (error) {
|
||||
// TikTok not connected or error - that's fine
|
||||
}
|
||||
}
|
||||
|
||||
const handleTikTokSync = async () => {
|
||||
setIsTiktokSyncing(true)
|
||||
try {
|
||||
const accountsResponse = await fetch('/api/tiktok/accounts')
|
||||
const accountsData = await accountsResponse.json()
|
||||
const activeAccount = accountsData.accounts?.find((a: { status: string }) => a.status === 'active')
|
||||
|
||||
if (activeAccount) {
|
||||
await fetch('/api/tiktok/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ account_id: activeAccount.id, sync_type: 'full' }),
|
||||
})
|
||||
await fetchTikTokStats()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('TikTok sync failed:', error)
|
||||
}
|
||||
setIsTiktokSyncing(false)
|
||||
}
|
||||
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0
|
||||
const currentMonth = new Date().getMonth() + 1
|
||||
@@ -111,11 +66,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
currentMonth
|
||||
)
|
||||
|
||||
const giftTaxableValue = summary.giftSummary?.taxable_value || 0
|
||||
const giftDeductibleValue = summary.giftSummary?.deductible_value || 0
|
||||
const netGiftTaxableIncome = giftTaxableValue - giftDeductibleValue
|
||||
|
||||
const totalTaxableIncome = summary.ytd.net + netGiftTaxableIncome
|
||||
const totalTaxableIncome = summary.ytd.net
|
||||
|
||||
const taxEstimate =
|
||||
entityType === 'enskild_firma'
|
||||
@@ -242,36 +193,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
)
|
||||
}
|
||||
|
||||
if (summary.giftSummary && summary.giftSummary.total_count > 0) {
|
||||
alertItems.push(
|
||||
<Link key="gifts" href="/gifts" className="group">
|
||||
<Card className="h-full border-l-4 border-l-accent hover:border-primary/30 transition-colors">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/10">
|
||||
<Gift className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">Gåvor & Förmåner</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{summary.giftSummary.total_count} produkter
|
||||
</p>
|
||||
{summary.giftSummary.taxable_count > 0 && (
|
||||
<Badge variant="destructive" className="mt-1.5">
|
||||
{formatCurrency(summary.giftSummary.taxable_value)} skattepliktig
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_ALERTS = 3
|
||||
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
|
||||
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
|
||||
@@ -280,7 +201,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
const quickActions = [
|
||||
{ href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true },
|
||||
{ href: '/receipts/scan', icon: Camera, label: 'Skanna kvitto', desc: 'Fotografera & spara' },
|
||||
{ href: '/campaigns/new', icon: Megaphone, label: 'Nytt samarbete', desc: 'Spåra innehåll' },
|
||||
{ href: '/customers', icon: Users, label: 'Ny kund', desc: 'Lägg till kunduppgifter' },
|
||||
{ href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Kategorisera' },
|
||||
]
|
||||
@@ -404,24 +324,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* TikTok stats widget */}
|
||||
{tiktokStats && (
|
||||
<section className="mb-8">
|
||||
<TikTokStatsWidget
|
||||
stats={tiktokStats}
|
||||
onSync={handleTikTokSync}
|
||||
isSyncing={isTiktokSyncing}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Active campaigns */}
|
||||
{summary.campaigns && summary.campaigns.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<CampaignsWidget campaigns={summary.campaigns} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Alerts section - with urgency indicators and limit */}
|
||||
{alertItems.length > 0 && (
|
||||
<section id="alerts-section" className="mb-16">
|
||||
|
||||
@@ -16,18 +16,14 @@ import {
|
||||
Settings,
|
||||
LogOut,
|
||||
Calculator,
|
||||
Gift,
|
||||
Upload,
|
||||
Calendar,
|
||||
Megaphone,
|
||||
FileUp,
|
||||
Camera,
|
||||
Menu,
|
||||
X,
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Wallet,
|
||||
} from 'lucide-react'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
@@ -48,19 +44,14 @@ interface NavItem {
|
||||
const navItems: NavItem[] = [
|
||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
|
||||
{ href: '/calendar', label: 'Kalender', icon: Calendar, group: 'main' },
|
||||
{ href: '/campaigns', label: 'Samarbeten', icon: Megaphone, group: 'main' },
|
||||
{ href: '/campaigns/import', label: 'Importera avtal', icon: FileUp, group: 'main' },
|
||||
{ href: '/analytics', label: 'Analytics', icon: BarChart3, group: 'main' },
|
||||
{ href: '/shadow-ledger', label: 'Utbetalningar', icon: Wallet, group: 'finans', modes: ['light'] },
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
|
||||
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
|
||||
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'finans' },
|
||||
{ href: '/receipts', label: 'Kvitton', icon: Camera, group: 'finans' },
|
||||
{ href: '/gifts', label: 'Gåvor', icon: Gift, group: 'finans' },
|
||||
{ href: '/deductions', label: 'Avdrag', icon: Calculator, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
|
||||
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans', modes: ['enskild_firma', 'aktiebolag'] },
|
||||
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt', modes: ['enskild_firma', 'aktiebolag'] },
|
||||
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'övrigt', modes: ['enskild_firma', 'aktiebolag'] },
|
||||
{ href: '/deductions', label: 'Avdrag', icon: Calculator, group: 'finans' },
|
||||
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans' },
|
||||
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt' },
|
||||
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'övrigt' },
|
||||
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
|
||||
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
|
||||
]
|
||||
@@ -101,20 +92,12 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
const finansItems = filteredItems.filter(i => i.group === 'finans')
|
||||
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
|
||||
|
||||
// Mobile nav items - light mode shows Utbetalningar instead of Fakturor
|
||||
const mobileNavItems = entityType === 'light'
|
||||
? [
|
||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
|
||||
{ href: '/campaigns', label: 'Samarbeten', icon: Megaphone },
|
||||
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
|
||||
{ href: '/shadow-ledger', label: 'Utbetalningar', icon: Wallet },
|
||||
]
|
||||
: [
|
||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
|
||||
{ href: '/campaigns', label: 'Samarbeten', icon: Megaphone },
|
||||
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
|
||||
]
|
||||
const mobileNavItems = [
|
||||
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
|
||||
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -128,7 +111,7 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
{companyName}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">
|
||||
{entityType === 'light' ? 'Egenanställd' : 'Ekonomi'}
|
||||
Ekonomi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { Gift, ArrowRight, CheckCircle2 } from 'lucide-react'
|
||||
|
||||
interface GiftTaxDebtCardProps {
|
||||
virtualTaxDebt: number
|
||||
taxableGiftCount: number
|
||||
effectiveRate: number
|
||||
}
|
||||
|
||||
export default function GiftTaxDebtCard({
|
||||
virtualTaxDebt,
|
||||
taxableGiftCount,
|
||||
effectiveRate,
|
||||
}: GiftTaxDebtCardProps) {
|
||||
const hasDebt = virtualTaxDebt > 0
|
||||
|
||||
return (
|
||||
<Link href="/gifts" className="group block">
|
||||
<Card className="h-full hover:border-primary/30 transition-colors">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gift className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">G\u00e5voskatt</CardTitle>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasDebt ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="font-display text-2xl font-medium tabular-nums text-amber-600">
|
||||
{formatCurrency(virtualTaxDebt)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Virtuell skatteskuld
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="tabular-nums font-medium text-foreground">
|
||||
{taxableGiftCount}
|
||||
</span>{' '}
|
||||
skattepliktiga g\u00e5vor
|
||||
</div>
|
||||
<span className="text-border">{'\u00b7'}</span>
|
||||
<div>
|
||||
<span className="tabular-nums font-medium text-foreground">
|
||||
{(effectiveRate * 100).toFixed(1)}%
|
||||
</span>{' '}
|
||||
skattesats
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-emerald-700">
|
||||
Ingen g\u00e5voskatt att betala
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Du har inga skattepliktiga g\u00e5vor registrerade i \u00e5r
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import SafeToSpendGauge from '@/components/dashboard/SafeToSpendGauge'
|
||||
import SGIShieldWidget from '@/components/dashboard/SGIShieldWidget'
|
||||
import GiftTaxDebtCard from '@/components/dashboard/GiftTaxDebtCard'
|
||||
import RecentPayoutsCard from '@/components/dashboard/RecentPayoutsCard'
|
||||
import {
|
||||
Banknote,
|
||||
Camera,
|
||||
Megaphone,
|
||||
Gift,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface RecentEntry {
|
||||
id: string
|
||||
date: string
|
||||
description: string | null
|
||||
gross_amount: number
|
||||
net_amount: number
|
||||
service_fee: number
|
||||
pension_deduction: number
|
||||
social_fees: number
|
||||
income_tax_withheld: number
|
||||
platform_fee: number
|
||||
type: string
|
||||
provider: string | null
|
||||
}
|
||||
|
||||
interface LightDashboardContentProps {
|
||||
firstName: string | null
|
||||
bankBalance: number | null
|
||||
giftTaxDebt: number
|
||||
taxableGiftCount: number
|
||||
effectiveRate: number
|
||||
daysSinceLastPayout: number | null
|
||||
recentEntries: RecentEntry[]
|
||||
hobbyReserve?: number
|
||||
}
|
||||
|
||||
export default function LightDashboardContent({
|
||||
firstName,
|
||||
bankBalance,
|
||||
giftTaxDebt,
|
||||
taxableGiftCount,
|
||||
effectiveRate,
|
||||
daysSinceLastPayout,
|
||||
recentEntries,
|
||||
hobbyReserve = 0,
|
||||
}: LightDashboardContentProps) {
|
||||
const greeting = (() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Godmorgon'
|
||||
if (hour < 18) return 'God eftermiddag'
|
||||
return 'God kv\u00e4ll'
|
||||
})()
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
href: '/shadow-ledger/new',
|
||||
icon: Banknote,
|
||||
label: 'Logga utbetalning',
|
||||
},
|
||||
{
|
||||
href: '/receipts/scan',
|
||||
icon: Camera,
|
||||
label: 'Skanna kvitto',
|
||||
},
|
||||
{
|
||||
href: '/campaigns/new',
|
||||
icon: Megaphone,
|
||||
label: 'Nytt samarbete',
|
||||
},
|
||||
{
|
||||
href: '/gifts',
|
||||
icon: Gift,
|
||||
label: 'Ny g\u00e5va',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stagger-enter">
|
||||
{/* Greeting header */}
|
||||
<header className="mb-10">
|
||||
<h1 className="font-display text-4xl md:text-5xl font-medium tracking-tight">
|
||||
{greeting}{firstName ? `, ${firstName}` : ''}!
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* SafeToSpendGauge - full width */}
|
||||
<section className="mb-6">
|
||||
<SafeToSpendGauge
|
||||
bankBalance={bankBalance ?? 0}
|
||||
giftTaxDebt={giftTaxDebt}
|
||||
hobbyReserve={hobbyReserve}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* SGIShieldWidget - full width */}
|
||||
<section className="mb-6">
|
||||
<SGIShieldWidget daysSinceLastPayout={daysSinceLastPayout} />
|
||||
</section>
|
||||
|
||||
{/* GiftTaxDebtCard + RecentPayoutsCard side by side on md+ */}
|
||||
<section className="grid md:grid-cols-2 gap-6 mb-10">
|
||||
<GiftTaxDebtCard
|
||||
virtualTaxDebt={giftTaxDebt}
|
||||
taxableGiftCount={taxableGiftCount}
|
||||
effectiveRate={effectiveRate}
|
||||
/>
|
||||
<RecentPayoutsCard entries={recentEntries} />
|
||||
</section>
|
||||
|
||||
{/* Quick actions row */}
|
||||
<section className="mb-12">
|
||||
<h2 className="font-display text-xl font-medium mb-4">Snabb\u00e5tg\u00e4rder</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<Link key={action.href} href={action.href}>
|
||||
<Button variant="outline" size="default" className="gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{action.label}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowRight, Banknote, Plus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface RecentPayoutsCardProps {
|
||||
entries: Array<{
|
||||
id: string
|
||||
date: string
|
||||
description: string | null
|
||||
gross_amount: number
|
||||
net_amount: number
|
||||
service_fee: number
|
||||
pension_deduction: number
|
||||
social_fees: number
|
||||
income_tax_withheld: number
|
||||
platform_fee: number
|
||||
type: string
|
||||
provider: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export default function RecentPayoutsCard({ entries }: RecentPayoutsCardProps) {
|
||||
const displayEntries = entries.slice(0, 5)
|
||||
const isEmpty = displayEntries.length === 0
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Banknote className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">Senaste utbetalningar</CardTitle>
|
||||
</div>
|
||||
{!isEmpty && (
|
||||
<Link
|
||||
href="/shadow-ledger"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
>
|
||||
Visa alla
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isEmpty ? (
|
||||
<div className="text-center py-6">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Inga utbetalningar registrerade
|
||||
</p>
|
||||
<Link href="/shadow-ledger/new">
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Logga utbetalning
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{displayEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between py-2 border-b border-border/50 last:border-0"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{entry.description || entry.provider || 'Utbetalning'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(entry.date)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right ml-4 flex-shrink-0">
|
||||
<p className="text-sm tabular-nums font-medium">
|
||||
{formatCurrency(entry.net_amount)}
|
||||
</p>
|
||||
{entry.gross_amount !== entry.net_amount && (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
brutto {formatCurrency(entry.gross_amount)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Shield, Info } from 'lucide-react'
|
||||
|
||||
interface SGIShieldWidgetProps {
|
||||
daysSinceLastPayout: number | null
|
||||
}
|
||||
|
||||
export default function SGIShieldWidget({
|
||||
daysSinceLastPayout,
|
||||
}: SGIShieldWidgetProps) {
|
||||
const maxDays = 90
|
||||
const days = daysSinceLastPayout ?? 0
|
||||
const progress = daysSinceLastPayout !== null ? Math.min((days / maxDays) * 100, 100) : 0
|
||||
|
||||
// Determine status
|
||||
let level: 'green' | 'yellow' | 'red'
|
||||
let statusMessage: string | null = null
|
||||
|
||||
if (daysSinceLastPayout === null) {
|
||||
level = 'green'
|
||||
} else if (days < 30) {
|
||||
level = 'green'
|
||||
} else if (days < 75) {
|
||||
level = 'yellow'
|
||||
statusMessage = 'Varning: Logga uppdrag snart'
|
||||
} else {
|
||||
level = 'red'
|
||||
statusMessage = 'Kritiskt: SGI riskerar nollst\u00e4llning'
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield
|
||||
className={cn(
|
||||
'h-5 w-5',
|
||||
level === 'green' && 'text-emerald-500',
|
||||
level === 'yellow' && 'text-amber-500',
|
||||
level === 'red' && 'text-red-500'
|
||||
)}
|
||||
/>
|
||||
<CardTitle className="text-lg">SGI-skydd</CardTitle>
|
||||
</div>
|
||||
{daysSinceLastPayout !== null && (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{days} dagar sedan senast
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{daysSinceLastPayout === null ? (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<Info className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga utbetalningar registrerade \u00e4nnu
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
SGI (sjukpenninggrundande inkomst) baseras p\u00e5 dina l\u00f6neutbetalningar.
|
||||
Logga din f\u00f6rsta utbetalning f\u00f6r att b\u00f6rja sp\u00e5ra.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full h-3 rounded-full overflow-hidden bg-muted mb-2">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
level === 'green' && 'bg-emerald-500',
|
||||
level === 'yellow' && 'bg-amber-500',
|
||||
level === 'red' && 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">{days}/{maxDays}</span>
|
||||
<span>N\u00e4sta varning: dag 30 {'\u00b7'} Kritisk: dag 75</span>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
{statusMessage && (
|
||||
<div className="mt-3">
|
||||
<Badge
|
||||
variant={level === 'red' ? 'destructive' : 'warning'}
|
||||
className="text-xs"
|
||||
>
|
||||
{statusMessage}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { Wallet } from 'lucide-react'
|
||||
|
||||
interface SafeToSpendGaugeProps {
|
||||
bankBalance: number
|
||||
giftTaxDebt: number
|
||||
hobbyReserve: number
|
||||
}
|
||||
|
||||
export default function SafeToSpendGauge({
|
||||
bankBalance,
|
||||
giftTaxDebt,
|
||||
hobbyReserve,
|
||||
}: SafeToSpendGaugeProps) {
|
||||
const safeToSpend = Math.max(0, bankBalance - giftTaxDebt - hobbyReserve)
|
||||
const total = bankBalance || 1 // Avoid division by zero
|
||||
|
||||
const safePercent = bankBalance > 0 ? (safeToSpend / total) * 100 : 0
|
||||
const giftPercent = bankBalance > 0 ? (giftTaxDebt / total) * 100 : 0
|
||||
const hobbyPercent = bankBalance > 0 ? (hobbyReserve / total) * 100 : 0
|
||||
|
||||
// Determine color level
|
||||
let level: 'green' | 'yellow' | 'red'
|
||||
if (safePercent > 70) {
|
||||
level = 'green'
|
||||
} else if (safePercent > 30) {
|
||||
level = 'yellow'
|
||||
} else {
|
||||
level = 'red'
|
||||
}
|
||||
|
||||
const noBankConnected = !bankBalance || bankBalance === 0
|
||||
|
||||
return (
|
||||
<Card className={cn(noBankConnected && 'opacity-60')}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">Att spendera</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{noBankConnected ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-2xl font-display font-medium text-muted-foreground">
|
||||
Ingen bank ansluten
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Koppla din bank f\u00f6r att se ditt disponibla belopp
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center mb-6">
|
||||
<p
|
||||
className={cn(
|
||||
'font-display text-4xl font-medium tabular-nums',
|
||||
level === 'green' && 'text-emerald-600',
|
||||
level === 'yellow' && 'text-amber-600',
|
||||
level === 'red' && 'text-red-600'
|
||||
)}
|
||||
>
|
||||
{formatCurrency(safeToSpend)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
av {formatCurrency(bankBalance)} p\u00e5 kontot
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stacked bar */}
|
||||
<div className="w-full h-4 rounded-full overflow-hidden flex bg-muted">
|
||||
{safePercent > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-500',
|
||||
level === 'green' && 'bg-emerald-500',
|
||||
level === 'yellow' && 'bg-amber-500',
|
||||
level === 'red' && 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${safePercent}%` }}
|
||||
/>
|
||||
)}
|
||||
{giftPercent > 0 && (
|
||||
<div
|
||||
className="h-full bg-amber-400 transition-all duration-500"
|
||||
style={{ width: `${giftPercent}%` }}
|
||||
/>
|
||||
)}
|
||||
{hobbyPercent > 0 && (
|
||||
<div
|
||||
className="h-full bg-orange-400 transition-all duration-500"
|
||||
style={{ width: `${hobbyPercent}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-2.5 w-2.5 rounded-full',
|
||||
level === 'green' && 'bg-emerald-500',
|
||||
level === 'yellow' && 'bg-amber-500',
|
||||
level === 'red' && 'bg-red-500'
|
||||
)}
|
||||
/>
|
||||
Disponibelt
|
||||
</div>
|
||||
{giftTaxDebt > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full bg-amber-400" />
|
||||
G\u00e5voskatt ({formatCurrency(giftTaxDebt)})
|
||||
</div>
|
||||
)}
|
||||
{hobbyReserve > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full bg-orange-400" />
|
||||
Hobby ({formatCurrency(hobbyReserve)})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -24,7 +24,7 @@ interface TourStep {
|
||||
position?: 'top' | 'bottom' | 'center'
|
||||
}
|
||||
|
||||
const TOUR_COMPLETED_KEY = 'influencer_dashboard_tour_completed'
|
||||
const TOUR_COMPLETED_KEY = 'erp_dashboard_tour_completed'
|
||||
|
||||
const tourSteps: TourStep[] = [
|
||||
{
|
||||
|
||||
@@ -36,7 +36,7 @@ interface NewUserChecklistProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CHECKLIST_DISMISSED_KEY = 'influencer_checklist_dismissed'
|
||||
const CHECKLIST_DISMISSED_KEY = 'erp_checklist_dismissed'
|
||||
|
||||
export default function NewUserChecklist({
|
||||
hasCustomers,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2, ArrowRight, Building2, User, Users, Check } from 'lucide-react'
|
||||
import { Loader2, ArrowRight, Building2, User, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
@@ -33,12 +33,6 @@ const entityOptions: {
|
||||
description: 'Du har ett registrerat AB med organisationsnummer',
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Egenanställd',
|
||||
description: 'Du arbetar via egenanställningsföretag som Frilans Finans, Cool Company eller Gigapay',
|
||||
icon: Users,
|
||||
},
|
||||
]
|
||||
|
||||
export default function Step1EntityType({ initialData, onNext, isSaving }: Step1Props) {
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function Step2CompanyDetails({
|
||||
</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
placeholder={isAB ? 'AB Företaget' : 'Alices Influencer-verksamhet'}
|
||||
placeholder={isAB ? 'AB Företaget' : 'Alices Konsultverksamhet'}
|
||||
{...register('company_name')}
|
||||
/>
|
||||
{errors.company_name && (
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, ArrowRight, ArrowLeft } from 'lucide-react'
|
||||
|
||||
interface Step2LightProps {
|
||||
initialData: { company_name?: string }
|
||||
onNext: (data: { company_name: string }) => void
|
||||
onBack: () => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export default function Step2LightPersonalInfo({
|
||||
initialData,
|
||||
onNext,
|
||||
onBack,
|
||||
isSaving,
|
||||
}: Step2LightProps) {
|
||||
const [name, setName] = useState(initialData.company_name || '')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) {
|
||||
setError('Namn krävs')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
onNext({ company_name: trimmed })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dina uppgifter</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Ange ditt namn som det visas i appen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-lg mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Personuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Ditt namn används i navigeringen och för att identifiera dig.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">Fullständigt namn *</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
placeholder="Anna Andersson"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (error) setError(null)
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Fortsätt
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,526 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Loader2, ArrowRight, ArrowLeft, Search, Check } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { MunicipalityRate, UmbrellaProviderDefault } from '@/types'
|
||||
|
||||
interface Step3LightProps {
|
||||
initialData: {
|
||||
municipality_code?: string
|
||||
municipal_tax_rate?: number
|
||||
church_tax?: boolean
|
||||
church_tax_rate?: number
|
||||
church_parish_code?: string
|
||||
umbrella_provider?: string
|
||||
umbrella_fee_percent?: number
|
||||
umbrella_pension_percent?: number
|
||||
umbrella_fee_custom?: boolean
|
||||
}
|
||||
onNext: (data: Record<string, unknown>) => void
|
||||
onBack: () => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export default function Step3LightTaxProfile({
|
||||
initialData,
|
||||
onNext,
|
||||
onBack,
|
||||
isSaving,
|
||||
}: Step3LightProps) {
|
||||
const supabase = createClient()
|
||||
|
||||
// Municipality state
|
||||
const [municipalitySearch, setMunicipalitySearch] = useState('')
|
||||
const [municipalityResults, setMunicipalityResults] = useState<MunicipalityRate[]>([])
|
||||
const [selectedMunicipality, setSelectedMunicipality] = useState<MunicipalityRate | null>(null)
|
||||
const [showMunicipalityDropdown, setShowMunicipalityDropdown] = useState(false)
|
||||
const [municipalityLoading, setMunicipalityLoading] = useState(false)
|
||||
|
||||
// Church tax state
|
||||
const [churchTax, setChurchTax] = useState(initialData.church_tax ?? false)
|
||||
const [parishes, setParishes] = useState<MunicipalityRate[]>([])
|
||||
const [selectedParish, setSelectedParish] = useState<MunicipalityRate | null>(null)
|
||||
const [parishLoading, setParishLoading] = useState(false)
|
||||
|
||||
// Umbrella provider state
|
||||
const [umbrellaProviders, setUmbrellaProviders] = useState<UmbrellaProviderDefault[]>([])
|
||||
const [selectedProvider, setSelectedProvider] = useState(initialData.umbrella_provider || '')
|
||||
const [feePercent, setFeePercent] = useState<number | null>(initialData.umbrella_fee_percent ?? null)
|
||||
const [pensionPercent, setPensionPercent] = useState<number | null>(initialData.umbrella_pension_percent ?? null)
|
||||
const [feeCustom, setFeeCustom] = useState(initialData.umbrella_fee_custom ?? false)
|
||||
const [showCustomFees, setShowCustomFees] = useState(initialData.umbrella_fee_custom ?? false)
|
||||
|
||||
// Form state
|
||||
const [municipalityCode, setMunicipalityCode] = useState(initialData.municipality_code || '')
|
||||
const [municipalTaxRate, setMunicipalTaxRate] = useState<number | null>(initialData.municipal_tax_rate ?? null)
|
||||
const [churchTaxRate, setChurchTaxRate] = useState<number | null>(initialData.church_tax_rate ?? null)
|
||||
const [churchParishCode, setChurchParishCode] = useState(initialData.church_parish_code || '')
|
||||
|
||||
// Debounced municipality search
|
||||
const searchMunicipalities = useCallback(async (query: string) => {
|
||||
if (query.length < 2) {
|
||||
setMunicipalityResults([])
|
||||
return
|
||||
}
|
||||
|
||||
setMunicipalityLoading(true)
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('municipality_tax_rates')
|
||||
.select('*')
|
||||
.ilike('municipality_name', `%${query}%`)
|
||||
.is('parish_code', null)
|
||||
.order('municipality_name')
|
||||
.limit(10)
|
||||
|
||||
if (!error && data) {
|
||||
setMunicipalityResults(data)
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setMunicipalityLoading(false)
|
||||
}
|
||||
}, [supabase])
|
||||
|
||||
// Debounce the municipality search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
searchMunicipalities(municipalitySearch)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [municipalitySearch, searchMunicipalities])
|
||||
|
||||
// Load initial municipality if we have a code
|
||||
useEffect(() => {
|
||||
if (initialData.municipality_code && !selectedMunicipality) {
|
||||
const loadMunicipality = async () => {
|
||||
const { data } = await supabase
|
||||
.from('municipality_tax_rates')
|
||||
.select('*')
|
||||
.eq('municipality_code', initialData.municipality_code!)
|
||||
.is('parish_code', null)
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (data) {
|
||||
setSelectedMunicipality(data)
|
||||
setMunicipalitySearch(data.municipality_name)
|
||||
}
|
||||
}
|
||||
loadMunicipality()
|
||||
}
|
||||
}, [initialData.municipality_code, selectedMunicipality, supabase])
|
||||
|
||||
// Load initial parish if we have a parish code
|
||||
useEffect(() => {
|
||||
if (initialData.church_parish_code && !selectedParish && initialData.municipality_code) {
|
||||
const loadParish = async () => {
|
||||
const { data } = await supabase
|
||||
.from('municipality_tax_rates')
|
||||
.select('*')
|
||||
.eq('parish_code', initialData.church_parish_code!)
|
||||
.eq('municipality_code', initialData.municipality_code!)
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (data) {
|
||||
setSelectedParish(data)
|
||||
}
|
||||
}
|
||||
loadParish()
|
||||
}
|
||||
}, [initialData.church_parish_code, initialData.municipality_code, selectedParish, supabase])
|
||||
|
||||
// Fetch parishes when municipality is selected and church tax is on
|
||||
useEffect(() => {
|
||||
if (churchTax && municipalityCode) {
|
||||
const fetchParishes = async () => {
|
||||
setParishLoading(true)
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('municipality_tax_rates')
|
||||
.select('*')
|
||||
.eq('municipality_code', municipalityCode)
|
||||
.not('parish_code', 'is', null)
|
||||
.order('parish_name')
|
||||
|
||||
if (!error && data) {
|
||||
setParishes(data)
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setParishLoading(false)
|
||||
}
|
||||
}
|
||||
fetchParishes()
|
||||
} else {
|
||||
setParishes([])
|
||||
}
|
||||
}, [churchTax, municipalityCode, supabase])
|
||||
|
||||
// Fetch umbrella providers on mount
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('umbrella_provider_defaults')
|
||||
.select('*')
|
||||
.order('display_name')
|
||||
|
||||
if (!error && data) {
|
||||
setUmbrellaProviders(data)
|
||||
}
|
||||
}
|
||||
fetchProviders()
|
||||
}, [supabase])
|
||||
|
||||
// Handle municipality selection
|
||||
const handleSelectMunicipality = (municipality: MunicipalityRate) => {
|
||||
setSelectedMunicipality(municipality)
|
||||
setMunicipalitySearch(municipality.municipality_name)
|
||||
setMunicipalityCode(municipality.municipality_code)
|
||||
setMunicipalTaxRate(municipality.total_rate / 100)
|
||||
setShowMunicipalityDropdown(false)
|
||||
|
||||
// Reset parish selection when municipality changes
|
||||
setSelectedParish(null)
|
||||
setChurchTaxRate(null)
|
||||
setChurchParishCode('')
|
||||
}
|
||||
|
||||
// Handle parish selection
|
||||
const handleSelectParish = (parishCode: string) => {
|
||||
const parish = parishes.find((p) => p.parish_code === parishCode)
|
||||
if (parish) {
|
||||
setSelectedParish(parish)
|
||||
setChurchTaxRate(parish.church_rate ? parish.church_rate / 100 : null)
|
||||
setChurchParishCode(parish.parish_code || '')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle provider selection
|
||||
const handleSelectProvider = (providerName: string) => {
|
||||
setSelectedProvider(providerName)
|
||||
|
||||
if (providerName === 'Annan') {
|
||||
setFeePercent(null)
|
||||
setPensionPercent(null)
|
||||
setShowCustomFees(true)
|
||||
setFeeCustom(true)
|
||||
return
|
||||
}
|
||||
|
||||
const provider = umbrellaProviders.find((p) => p.display_name === providerName)
|
||||
if (provider) {
|
||||
setFeePercent(provider.default_fee_percent)
|
||||
setPensionPercent(provider.pension_percent)
|
||||
if (!showCustomFees) {
|
||||
setFeeCustom(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submit
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onNext({
|
||||
municipality_code: municipalityCode,
|
||||
municipal_tax_rate: municipalTaxRate,
|
||||
church_tax: churchTax,
|
||||
church_tax_rate: churchTax ? churchTaxRate : null,
|
||||
church_parish_code: churchTax ? churchParishCode : null,
|
||||
umbrella_provider: selectedProvider || null,
|
||||
umbrella_fee_percent: feePercent,
|
||||
umbrella_pension_percent: pensionPercent,
|
||||
umbrella_fee_custom: feeCustom,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Skatteprofil</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Ange din kommun och egenanställningsföretag för korrekt skatteberäkning
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-lg mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Kommun</CardTitle>
|
||||
<CardDescription>
|
||||
Din kommunalskattesats används för att uppskatta skatt på gåvor och hobbyinkomst.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Municipality search */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="municipality_search">Sök kommun</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="municipality_search"
|
||||
placeholder="Skriv kommunnamn..."
|
||||
value={municipalitySearch}
|
||||
onChange={(e) => {
|
||||
setMunicipalitySearch(e.target.value)
|
||||
setShowMunicipalityDropdown(true)
|
||||
if (selectedMunicipality) {
|
||||
setSelectedMunicipality(null)
|
||||
setMunicipalityCode('')
|
||||
setMunicipalTaxRate(null)
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (municipalityResults.length > 0) {
|
||||
setShowMunicipalityDropdown(true)
|
||||
}
|
||||
}}
|
||||
className="pl-9"
|
||||
/>
|
||||
{municipalityLoading && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{/* Municipality dropdown */}
|
||||
{showMunicipalityDropdown && municipalityResults.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border/60 bg-popover shadow-md max-h-60 overflow-auto">
|
||||
{municipalityResults.map((municipality) => (
|
||||
<button
|
||||
key={municipality.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-4 py-2.5 text-sm hover:bg-secondary transition-colors"
|
||||
onClick={() => handleSelectMunicipality(municipality)}
|
||||
>
|
||||
<span>{municipality.municipality_name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{municipality.total_rate.toFixed(2)}%
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected municipality display */}
|
||||
{selectedMunicipality && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm">
|
||||
{selectedMunicipality.municipality_name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{selectedMunicipality.total_rate.toFixed(2)}% kommunalskatt
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Church membership section */}
|
||||
<div className="pt-4 border-t space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="church_tax">Medlem i Svenska kyrkan?</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kyrkoskatt tillkommer utöver kommunalskatten
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="church_tax"
|
||||
checked={churchTax}
|
||||
onCheckedChange={(checked) => {
|
||||
setChurchTax(checked)
|
||||
if (!checked) {
|
||||
setSelectedParish(null)
|
||||
setChurchTaxRate(null)
|
||||
setChurchParishCode('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{churchTax && municipalityCode && (
|
||||
<div className="space-y-2">
|
||||
<Label>Församling</Label>
|
||||
{parishLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Laddar församlingar...
|
||||
</div>
|
||||
) : parishes.length > 0 ? (
|
||||
<Select
|
||||
value={churchParishCode}
|
||||
onValueChange={handleSelectParish}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj församling" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parishes.map((parish) => (
|
||||
<SelectItem
|
||||
key={parish.parish_code}
|
||||
value={parish.parish_code!}
|
||||
>
|
||||
{parish.parish_name} ({parish.church_rate?.toFixed(2)}%)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga församlingar hittades för vald kommun.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedParish && selectedParish.church_rate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm">
|
||||
Kyrkoskatt: {selectedParish.church_rate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{churchTax && !municipalityCode && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Välj kommun ovan för att se församlingar.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Umbrella provider section */}
|
||||
<div className="pt-4 border-t space-y-4">
|
||||
<div>
|
||||
<Label>Egenanställningsföretag</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Välj det företag du arbetar via för att beräkna avgifter korrekt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onValueChange={handleSelectProvider}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj egenanställningsföretag" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{umbrellaProviders.map((provider) => (
|
||||
<SelectItem
|
||||
key={provider.id}
|
||||
value={provider.display_name}
|
||||
>
|
||||
{provider.display_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="Annan">Annan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedProvider && feePercent !== null && !showCustomFees && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Badge variant="secondary">
|
||||
Avgift: {feePercent}%
|
||||
</Badge>
|
||||
{pensionPercent !== null && pensionPercent > 0 && (
|
||||
<Badge variant="secondary">
|
||||
Pension: {pensionPercent}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider && !showCustomFees && selectedProvider !== 'Annan' && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-primary hover:underline"
|
||||
onClick={() => {
|
||||
setShowCustomFees(true)
|
||||
setFeeCustom(true)
|
||||
}}
|
||||
>
|
||||
Anpassa avgifter
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showCustomFees && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fee_percent">Avgift (%)</Label>
|
||||
<Input
|
||||
id="fee_percent"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="t.ex. 6"
|
||||
value={feePercent ?? ''}
|
||||
onChange={(e) => {
|
||||
setFeePercent(e.target.value ? parseFloat(e.target.value) : null)
|
||||
setFeeCustom(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pension_percent">Pension (%)</Label>
|
||||
<Input
|
||||
id="pension_percent"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="t.ex. 4.5"
|
||||
value={pensionPercent ?? ''}
|
||||
onChange={(e) => {
|
||||
setPensionPercent(e.target.value ? parseFloat(e.target.value) : null)
|
||||
setFeeCustom(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Fortsätt
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user