Refactor to generic ERP base
Remove influencer-specific features (campaigns, TikTok, gifts, shadow ledger, contracts, briefings) and consolidate into a clean ERP foundation with core bookkeeping, invoicing, receipts, tax reporting, and calendar functionality. Reorganize database migrations into a clean numbered sequence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a25d10a528
commit
0aecf5b41c
@@ -1,473 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
X,
|
||||
Camera,
|
||||
Check,
|
||||
Loader2,
|
||||
Gift,
|
||||
Building,
|
||||
User,
|
||||
HelpCircle,
|
||||
ArrowRight,
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import ReceiptCamera from './ReceiptCamera'
|
||||
import type { GiftClassification, Gift as GiftType } from '@/types'
|
||||
|
||||
interface ProductCaptureProps {
|
||||
onComplete: (gift: GiftType) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
type Step = 'capture' | 'details' | 'classification' | 'result'
|
||||
|
||||
export default function ProductCapture({ onComplete, onCancel }: ProductCaptureProps) {
|
||||
const [step, setStep] = useState<Step>('capture')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [isEstimating, setIsEstimating] = useState(false)
|
||||
|
||||
// Image data
|
||||
const [imageData, setImageData] = useState<string | null>(null)
|
||||
const [mimeType, setMimeType] = useState<string>('image/jpeg')
|
||||
|
||||
// Product details
|
||||
const [brandName, setBrandName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [estimatedValue, setEstimatedValue] = useState<number | null>(null)
|
||||
const [aiEstimate, setAiEstimate] = useState<{ value: number; confidence: number } | null>(null)
|
||||
|
||||
// Classification inputs
|
||||
const [hasMotprestation, setHasMotprestation] = useState(false)
|
||||
const [usedInBusiness, setUsedInBusiness] = useState(false)
|
||||
const [usedPrivately, setUsedPrivately] = useState(false)
|
||||
const [isSimplePromo, setIsSimplePromo] = useState(false)
|
||||
|
||||
// Result
|
||||
const [classification, setClassification] = useState<GiftClassification | null>(null)
|
||||
const [createdGift, setCreatedGift] = useState<GiftType | null>(null)
|
||||
|
||||
// Handle image capture
|
||||
const handleCapture = async (base64Data: string, type: string) => {
|
||||
setImageData(base64Data)
|
||||
setMimeType(type)
|
||||
setStep('details')
|
||||
|
||||
// Call AI estimate in the background (non-blocking)
|
||||
setIsEstimating(true)
|
||||
try {
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('image', blob, 'product.jpg')
|
||||
|
||||
const response = await fetch('/api/gifts/estimate', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const { data } = await response.json()
|
||||
if (data) {
|
||||
setAiEstimate({ value: data.estimatedValue, confidence: data.confidence })
|
||||
// Only pre-fill empty fields
|
||||
setEstimatedValue((prev) => prev ?? data.estimatedValue)
|
||||
setDescription((prev) => (prev ? prev : data.description || ''))
|
||||
setBrandName((prev) => (prev ? prev : data.brand || ''))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently continue — user can still enter values manually
|
||||
console.error('AI estimation failed:', error)
|
||||
} finally {
|
||||
setIsEstimating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle details submission
|
||||
const handleDetailsSubmit = () => {
|
||||
if (!estimatedValue || estimatedValue <= 0) {
|
||||
alert('Ange ett uppskattat värde')
|
||||
return
|
||||
}
|
||||
if (!description.trim()) {
|
||||
alert('Ange en beskrivning av produkten')
|
||||
return
|
||||
}
|
||||
setStep('classification')
|
||||
}
|
||||
|
||||
// Handle classification submission
|
||||
const handleClassificationSubmit = async () => {
|
||||
if (!imageData || !estimatedValue) return
|
||||
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
|
||||
// Convert base64 to blob
|
||||
const byteCharacters = atob(imageData)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: mimeType })
|
||||
formData.append('image', blob, 'product.jpg')
|
||||
|
||||
formData.append('estimated_value', estimatedValue.toString())
|
||||
formData.append('brand_name', brandName || 'Okänt varumärke')
|
||||
formData.append('description', description)
|
||||
formData.append('has_motprestation', hasMotprestation.toString())
|
||||
formData.append('used_in_business', usedInBusiness.toString())
|
||||
formData.append('used_privately', usedPrivately.toString())
|
||||
formData.append('is_simple_promo', isSimplePromo.toString())
|
||||
|
||||
const response = await fetch('/api/receipts/product', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.data) {
|
||||
setClassification(data.data.classification)
|
||||
setCreatedGift(data.data.gift)
|
||||
setStep('result')
|
||||
} else {
|
||||
alert(data.error || 'Kunde inte registrera produkten')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submit error:', error)
|
||||
alert('Något gick fel. Försök igen.')
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
const handleComplete = () => {
|
||||
if (createdGift) {
|
||||
onComplete(createdGift)
|
||||
}
|
||||
}
|
||||
|
||||
// Render camera step
|
||||
if (step === 'capture') {
|
||||
return <ReceiptCamera onCapture={handleCapture} onClose={onCancel} />
|
||||
}
|
||||
|
||||
// Render details step
|
||||
if (step === 'details') {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background z-50 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<Button variant="ghost" size="icon" onClick={onCancel}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="font-semibold">Produktdetaljer</h1>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
{/* Image preview */}
|
||||
{imageData && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={`data:${mimeType};base64,${imageData}`}
|
||||
alt="Product"
|
||||
className="w-full max-h-48 object-contain rounded-lg bg-muted"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="absolute bottom-2 right-2"
|
||||
onClick={() => setStep('capture')}
|
||||
>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
Ta om
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI estimation indicator */}
|
||||
{isEstimating && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">AI analyserar produkten...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product details form */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="brand">Varumärke (valfritt)</Label>
|
||||
<Input
|
||||
id="brand"
|
||||
value={brandName}
|
||||
onChange={(e) => setBrandName(e.target.value)}
|
||||
placeholder="T.ex. Apple, Nike, Samsung..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Beskrivning *</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Vad är det för produkt?"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="value">Uppskattat marknadsvärde (SEK) *</Label>
|
||||
<Input
|
||||
id="value"
|
||||
type="number"
|
||||
min={0}
|
||||
value={estimatedValue || ''}
|
||||
onChange={(e) => setEstimatedValue(parseFloat(e.target.value) || null)}
|
||||
placeholder="0"
|
||||
/>
|
||||
{aiEstimate && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
AI-uppskattning: {formatCurrency(aiEstimate.value, 'SEK')}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{Math.round(aiEstimate.confidence * 100)}% säkerhet
|
||||
</Badge>
|
||||
{estimatedValue !== aiEstimate.value && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs"
|
||||
onClick={() => setEstimatedValue(aiEstimate.value)}
|
||||
>
|
||||
Använd AI-uppskattning
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t">
|
||||
<Button className="w-full" onClick={handleDetailsSubmit} disabled={isProcessing}>
|
||||
Fortsätt
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render classification step
|
||||
if (step === 'classification') {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background z-50 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<Button variant="ghost" size="icon" onClick={() => setStep('details')}>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="font-semibold">Klassificera förmån</h1>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
{/* Product summary */}
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Gift className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{description}</p>
|
||||
<p className="text-lg font-bold">{formatCurrency(estimatedValue || 0, 'SEK')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Classification questions */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
Frågor för klassificering
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Motprestation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Krävdes motprestation?</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
T.ex. inlägg, recension, omnämnande
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={hasMotprestation}
|
||||
onCheckedChange={setHasMotprestation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Used in business */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Används i verksamheten?</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Som rekvisita, utrustning, material
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={usedInBusiness}
|
||||
onCheckedChange={setUsedInBusiness}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Used privately */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Används privat?</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
För eget bruk, hemma, fritid
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={usedPrivately}
|
||||
onCheckedChange={setUsedPrivately}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Simple promo */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Enkel reklamprodukt?</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Penna, mugg, t-shirt med logga
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isSimplePromo}
|
||||
onCheckedChange={setIsSimplePromo}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t">
|
||||
<Button className="w-full" onClick={handleClassificationSubmit} disabled={isProcessing}>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Klassificerar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Klassificera
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render result step
|
||||
if (step === 'result' && classification) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background z-50 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="w-10" />
|
||||
<h1 className="font-semibold">Resultat</h1>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
{/* Result card */}
|
||||
<Card className={classification.taxable ? 'border-orange-500' : 'border-green-500'}>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div
|
||||
className={`h-16 w-16 rounded-full flex items-center justify-center mx-auto mb-4 ${
|
||||
classification.taxable ? 'bg-orange-100' : 'bg-green-100'
|
||||
}`}
|
||||
>
|
||||
{classification.taxable ? (
|
||||
<User className="h-8 w-8 text-orange-600" />
|
||||
) : (
|
||||
<Building className="h-8 w-8 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold mb-2">
|
||||
{classification.taxable ? 'Skattepliktig förmån' : 'Skattefri förmån'}
|
||||
</h2>
|
||||
|
||||
<Badge variant={classification.taxable ? 'destructive' : 'default'} className="mb-4">
|
||||
{formatCurrency(classification.marketValue, 'SEK')}
|
||||
</Badge>
|
||||
|
||||
<p className="text-sm text-muted-foreground">{classification.reasoning}</p>
|
||||
|
||||
{classification.deductibleAsExpense && (
|
||||
<div className="mt-4 p-3 rounded-lg bg-green-50 dark:bg-green-950/20 text-sm">
|
||||
<p className="font-medium text-green-700 dark:text-green-300">
|
||||
Avdragsgill som företagskostnad
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Booking type info */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Bokföringstyp</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="outline">{classification.bookingType}</Badge>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{classification.bookingType === 'income' &&
|
||||
'Bokförs som intäkt (förmånsvärde)'}
|
||||
{classification.bookingType === 'income_and_expense' &&
|
||||
'Bokförs som både intäkt och kostnad'}
|
||||
{classification.bookingType === 'tax_free' &&
|
||||
'Ingen bokföringsåtgärd krävs'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t">
|
||||
<Button className="w-full" onClick={handleComplete}>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Klar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -3,13 +3,12 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Flame, Receipt, CreditCard, Camera, Package, ArrowRight } from 'lucide-react'
|
||||
import { Flame, Receipt, CreditCard, Camera, ArrowRight } from 'lucide-react'
|
||||
import type { ReceiptQueueSummary } from '@/types'
|
||||
|
||||
interface ReceiptDashboardProps {
|
||||
summary: ReceiptQueueSummary
|
||||
onScanReceipt: () => void
|
||||
onRegisterProduct: () => void
|
||||
onViewReceiptQueue: () => void
|
||||
onViewTransactionQueue: () => void
|
||||
}
|
||||
@@ -17,7 +16,6 @@ interface ReceiptDashboardProps {
|
||||
export default function ReceiptDashboard({
|
||||
summary,
|
||||
onScanReceipt,
|
||||
onRegisterProduct,
|
||||
onViewReceiptQueue,
|
||||
onViewTransactionQueue,
|
||||
}: ReceiptDashboardProps) {
|
||||
@@ -29,7 +27,7 @@ export default function ReceiptDashboard({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Quick actions */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Button
|
||||
variant="default"
|
||||
className="h-auto py-4 flex-col gap-2"
|
||||
@@ -38,14 +36,6 @@ export default function ReceiptDashboard({
|
||||
<Camera className="h-6 w-6" />
|
||||
<span>Skanna kvitto</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-4 flex-col gap-2"
|
||||
onClick={onRegisterProduct}
|
||||
>
|
||||
<Package className="h-6 w-6" />
|
||||
<span>Logga produkt</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Streak counter */}
|
||||
|
||||
@@ -3,4 +3,3 @@ export { default as ReceiptLineItemRow } from './ReceiptLineItemRow'
|
||||
export { default as ReceiptReviewView } from './ReceiptReviewView'
|
||||
export { default as TransactionMatcher } from './TransactionMatcher'
|
||||
export { default as ReceiptDashboard } from './ReceiptDashboard'
|
||||
export { default as ProductCapture } from './ProductCapture'
|
||||
|
||||
Reference in New Issue
Block a user