Refactor to generic ERP base
Remove influencer-specific features (campaigns, TikTok, gifts, shadow ledger, contracts, briefings) and consolidate into a clean ERP foundation with core bookkeeping, invoicing, receipts, tax reporting, and calendar functionality. Reorganize database migrations into a clean numbered sequence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a25d10a528
commit
0aecf5b41c
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user