refactor: clean up codebase, remove dead code and obsolete docs
Remove influencer-era documentation, unused components, boilerplate assets, and ghost tiktok cron job. Add supplier invoice management, document API routes, and PWA icons. Replace boilerplate README. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e8743e6e03
commit
838dc6b8b5
@@ -27,8 +27,8 @@ export function BankConnectionStatus({
|
||||
onDisconnect,
|
||||
isSyncing = false,
|
||||
}: BankConnectionStatusProps) {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires_at)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires_at)
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
|
||||
|
||||
const statusConfig = {
|
||||
active: {
|
||||
@@ -61,7 +61,7 @@ export function BankConnectionStatus({
|
||||
const StatusIcon = status.icon
|
||||
|
||||
// Parse accounts from connection
|
||||
const accounts = (connection.accounts as Array<{
|
||||
const accounts = (connection.accounts_data as Array<{
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
X,
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
Building2,
|
||||
FileInput,
|
||||
} from 'lucide-react'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
@@ -43,6 +45,8 @@ const navItems: NavItem[] = [
|
||||
{ href: '/calendar', label: 'Kalender', icon: Calendar, group: 'main' },
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
|
||||
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
|
||||
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans' },
|
||||
{ href: '/supplier-invoices', label: 'Lev.fakturor', icon: FileInput, group: 'finans' },
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'finans' },
|
||||
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans' },
|
||||
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt' },
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
|
||||
interface MonthlyData {
|
||||
month: string
|
||||
income: number
|
||||
expenses: number
|
||||
}
|
||||
|
||||
interface IncomeChartProps {
|
||||
monthlyData: MonthlyData[]
|
||||
}
|
||||
|
||||
export function IncomeChart({ monthlyData }: IncomeChartProps) {
|
||||
const { trendPercent, trendDirection } = useMemo(() => {
|
||||
if (monthlyData.length < 2) return { trendPercent: 0, trendDirection: 'flat' as const }
|
||||
const current = monthlyData[monthlyData.length - 1]
|
||||
const previous = monthlyData[monthlyData.length - 2]
|
||||
if (previous.income === 0) return { trendPercent: 0, trendDirection: 'flat' as const }
|
||||
const percent = Math.round(((current.income - previous.income) / previous.income) * 100)
|
||||
return {
|
||||
trendPercent: Math.abs(percent),
|
||||
trendDirection: percent > 0 ? 'up' as const : percent < 0 ? 'down' as const : 'flat' as const,
|
||||
}
|
||||
}, [monthlyData])
|
||||
|
||||
if (monthlyData.length === 0) return null
|
||||
|
||||
const formatAmount = (value: number) => {
|
||||
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`
|
||||
if (value >= 1000) return `${Math.round(value / 1000)}k`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-muted-foreground">Intäkter vs kostnader</span>
|
||||
{trendDirection !== 'flat' && (
|
||||
<div className={`flex items-center gap-1 text-xs font-medium ${trendDirection === 'up' ? 'text-success' : 'text-destructive'}`}>
|
||||
{trendDirection === 'up' ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{trendPercent}% vs förra månaden
|
||||
</div>
|
||||
)}
|
||||
{trendDirection === 'flat' && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
Oförändrat
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-[180px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={monthlyData} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="incomeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(145, 20%, 36%)" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="hsl(145, 20%, 36%)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="expenseGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(0, 45%, 55%)" stopOpacity={0.1} />
|
||||
<stop offset="95%" stopColor="hsl(0, 45%, 55%)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fontSize: 11, fill: 'hsl(30, 8%, 45%)' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: 'hsl(30, 8%, 45%)' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={formatAmount}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(40, 40%, 99%)',
|
||||
border: '1px solid hsl(35, 20%, 88%)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => {
|
||||
if (value === undefined) return ['-', name || '']
|
||||
return [
|
||||
new Intl.NumberFormat('sv-SE', { style: 'currency', currency: 'SEK', maximumFractionDigits: 0 }).format(value),
|
||||
name === 'income' ? 'Intäkter' : 'Kostnader',
|
||||
]
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="income"
|
||||
stroke="hsl(145, 20%, 36%)"
|
||||
strokeWidth={2}
|
||||
fill="url(#incomeGradient)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="expenses"
|
||||
stroke="hsl(0, 45%, 55%)"
|
||||
strokeWidth={1.5}
|
||||
fill="url(#expenseGradient)"
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-6 mt-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-0.5 bg-primary rounded" />
|
||||
Intäkter
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-0.5 bg-destructive rounded border-dashed" style={{ borderTop: '2px dashed hsl(0, 45%, 55%)' }} />
|
||||
Kostnader
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
X,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Sparkles,
|
||||
PiggyBank,
|
||||
AlertTriangle,
|
||||
ListTodo,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TourStep {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
highlight?: string // CSS selector or element ID to highlight
|
||||
position?: 'top' | 'bottom' | 'center'
|
||||
}
|
||||
|
||||
const TOUR_COMPLETED_KEY = 'erp_dashboard_tour_completed'
|
||||
|
||||
const tourSteps: TourStep[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: 'Välkommen till din översikt!',
|
||||
description:
|
||||
'Här ser du hela din ekonomiska situation på ett ställe. Låt oss gå igenom de viktigaste delarna.',
|
||||
icon: Sparkles,
|
||||
position: 'center',
|
||||
},
|
||||
{
|
||||
id: 'disponibelt',
|
||||
title: 'Ditt att spendera',
|
||||
description:
|
||||
'Detta är vad du faktiskt kan använda efter att vi räknat bort uppskattad skatt och moms. Tänk på det som "säkra pengar".',
|
||||
icon: PiggyBank,
|
||||
highlight: 'disponibelt-section',
|
||||
position: 'bottom',
|
||||
},
|
||||
{
|
||||
id: 'fskatt',
|
||||
title: 'F-skatt varning',
|
||||
description:
|
||||
'Vi jämför din faktiska skatt med vad du betalat in via F-skatt. Om det skiljer sig mycket får du en varning så du kan justera.',
|
||||
icon: AlertTriangle,
|
||||
highlight: 'fskatt-section',
|
||||
position: 'top',
|
||||
},
|
||||
{
|
||||
id: 'att-hantera',
|
||||
title: 'Att hantera',
|
||||
description:
|
||||
'Här samlas saker som behöver din uppmärksamhet: okategoriserade transaktioner, obetalda fakturor, och liknande.',
|
||||
icon: ListTodo,
|
||||
highlight: 'alerts-section',
|
||||
position: 'top',
|
||||
},
|
||||
{
|
||||
id: 'snabbatgarder',
|
||||
title: 'Snabbåtgärder',
|
||||
description:
|
||||
'Skapa fakturor, logga körningar eller skanna kvitton med ett klick. De vanligaste uppgifterna finns alltid nära till hands.',
|
||||
icon: Zap,
|
||||
highlight: 'quick-actions',
|
||||
position: 'top',
|
||||
},
|
||||
]
|
||||
|
||||
interface DashboardTourProps {
|
||||
onComplete?: () => void
|
||||
forceShow?: boolean
|
||||
}
|
||||
|
||||
export default function DashboardTour({ onComplete, forceShow = false }: DashboardTourProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if tour has been completed
|
||||
const completed = localStorage.getItem(TOUR_COMPLETED_KEY)
|
||||
if (forceShow || completed !== 'true') {
|
||||
// Small delay to let the page render first
|
||||
const timer = setTimeout(() => setIsVisible(true), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [forceShow])
|
||||
|
||||
const completeTour = useCallback(() => {
|
||||
setIsAnimating(true)
|
||||
setTimeout(() => {
|
||||
localStorage.setItem(TOUR_COMPLETED_KEY, 'true')
|
||||
setIsVisible(false)
|
||||
onComplete?.()
|
||||
}, 300)
|
||||
}, [onComplete])
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep < tourSteps.length - 1) {
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
} else {
|
||||
completeTour()
|
||||
}
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const skipTour = () => {
|
||||
completeTour()
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
const step = tourSteps[currentStep]
|
||||
const Icon = step.icon
|
||||
const isLastStep = currentStep === tourSteps.length - 1
|
||||
const isFirstStep = currentStep === 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-background/80 backdrop-blur-sm z-50 transition-opacity duration-300',
|
||||
isAnimating ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
onClick={skipTour}
|
||||
/>
|
||||
|
||||
{/* Tour card */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-50 transition-all duration-300',
|
||||
step.position === 'center' && 'inset-0 flex items-center justify-center p-4',
|
||||
step.position === 'top' && 'top-4 left-1/2 -translate-x-1/2 w-full max-w-md px-4',
|
||||
step.position === 'bottom' && 'bottom-20 md:bottom-4 left-1/2 -translate-x-1/2 w-full max-w-md px-4',
|
||||
isAnimating && 'opacity-0 scale-95'
|
||||
)}
|
||||
>
|
||||
<Card className="shadow-2xl border-primary/20">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header with icon and close */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">{step.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Steg {currentStep + 1} av {tourSteps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 -mr-2 -mt-2"
|
||||
onClick={skipTour}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Stäng guide</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="flex items-center justify-center gap-1.5 py-2">
|
||||
{tourSteps.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentStep(idx)}
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-all',
|
||||
idx === currentStep
|
||||
? 'bg-primary w-4'
|
||||
: idx < currentStep
|
||||
? 'bg-primary/40'
|
||||
: 'bg-muted-foreground/20'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={prevStep}
|
||||
disabled={isFirstStep}
|
||||
className={cn(isFirstStep && 'invisible')}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={skipTour}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Hoppa över
|
||||
</Button>
|
||||
|
||||
<Button size="sm" onClick={nextStep}>
|
||||
{isLastStep ? (
|
||||
'Kom igång'
|
||||
) : (
|
||||
<>
|
||||
Nästa
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook för att starta om touren
|
||||
*/
|
||||
export function useDashboardTour() {
|
||||
const resetTour = () => {
|
||||
localStorage.removeItem(TOUR_COMPLETED_KEY)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const isTourCompleted = () => {
|
||||
return localStorage.getItem(TOUR_COMPLETED_KEY) === 'true'
|
||||
}
|
||||
|
||||
return { resetTour, isTourCompleted }
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { Loader2, ArrowRight, ArrowLeft, Landmark } from 'lucide-react'
|
||||
|
||||
const schema = z.object({
|
||||
bank_name: z.string().optional(),
|
||||
clearing_number: z.string().optional(),
|
||||
account_number: z.string().optional(),
|
||||
iban: z.string().optional(),
|
||||
bic: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
interface Step5Props {
|
||||
initialData: Partial<FormData>
|
||||
onNext: (data: FormData) => void
|
||||
onBack: () => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export default function Step5BankDetails({
|
||||
initialData,
|
||||
onNext,
|
||||
onBack,
|
||||
isSaving,
|
||||
}: Step5Props) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
bank_name: initialData.bank_name || '',
|
||||
clearing_number: initialData.clearing_number || '',
|
||||
account_number: initialData.account_number || '',
|
||||
iban: initialData.iban || '',
|
||||
bic: initialData.bic || '',
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Bankuppgifter för fakturor</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Dessa uppgifter visas på dina fakturor så att kunder kan betala dig.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Landmark className="h-5 w-5" />
|
||||
Bankkonto
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ange kontot som kunder ska betala till.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onNext)} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<Input
|
||||
id="bank_name"
|
||||
placeholder="t.ex. Nordea, SEB, Swedbank"
|
||||
{...register('bank_name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är clearingnummer?</p>
|
||||
<p>De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Nordea: 3300</li>
|
||||
<li>SEB: 5000</li>
|
||||
<li>Swedbank: 8XXX</li>
|
||||
<li>Handelsbanken: 6XXX</li>
|
||||
<li>Avanza: 9550/9551</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
>
|
||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
placeholder="XXXX"
|
||||
{...register('clearing_number')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
placeholder="XXX XXX XXX"
|
||||
{...register('account_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="font-medium mb-4">Internationella betalningar (valfritt)</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
För kunder utanför Sverige som betalar via banköverföring.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är IBAN?</p>
|
||||
<p>Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.</p>
|
||||
<p className="text-xs text-muted-foreground">Din bank kan ge dig ditt IBAN - kolla internetbanken eller kontakta dem.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="iban"
|
||||
placeholder="SE00 0000 0000 0000 0000 0000"
|
||||
{...register('iban')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är BIC/SWIFT?</p>
|
||||
<p>Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Nordea: NDEASESS</li>
|
||||
<li>SEB: ESSESESS</li>
|
||||
<li>Swedbank: SWEDSESS</li>
|
||||
<li>Handelsbanken: HANDSESS</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="bic">BIC/SWIFT</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="bic"
|
||||
placeholder="XXXXSESS"
|
||||
{...register('bic')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Fortsätt
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client'
|
||||
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
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 { Loader2 } from 'lucide-react'
|
||||
import type { CreateSupplierInput, SupplierType } from '@/types'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Namn krävs'),
|
||||
supplier_type: z.enum(['swedish_business', 'eu_business', 'non_eu_business']),
|
||||
email: z.string().email('Ogiltig e-postadress').optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
bankgiro: z.string().optional(),
|
||||
plusgiro: z.string().optional(),
|
||||
iban: z.string().optional(),
|
||||
bic: z.string().optional(),
|
||||
default_expense_account: z.string().optional(),
|
||||
default_payment_terms: z.number().min(1).optional(),
|
||||
default_currency: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
interface SupplierFormProps {
|
||||
onSubmit: (data: CreateSupplierInput) => Promise<void>
|
||||
isLoading: boolean
|
||||
initialData?: Partial<FormData>
|
||||
}
|
||||
|
||||
export default function SupplierForm({
|
||||
onSubmit,
|
||||
isLoading,
|
||||
initialData,
|
||||
}: SupplierFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: initialData?.name || '',
|
||||
supplier_type: initialData?.supplier_type || 'swedish_business',
|
||||
email: initialData?.email || '',
|
||||
phone: initialData?.phone || '',
|
||||
address_line1: initialData?.address_line1 || '',
|
||||
postal_code: initialData?.postal_code || '',
|
||||
city: initialData?.city || '',
|
||||
country: initialData?.country || 'SE',
|
||||
org_number: initialData?.org_number || '',
|
||||
vat_number: initialData?.vat_number || '',
|
||||
bankgiro: initialData?.bankgiro || '',
|
||||
plusgiro: initialData?.plusgiro || '',
|
||||
iban: initialData?.iban || '',
|
||||
bic: initialData?.bic || '',
|
||||
default_expense_account: initialData?.default_expense_account || '',
|
||||
default_payment_terms: initialData?.default_payment_terms || 30,
|
||||
default_currency: initialData?.default_currency || 'SEK',
|
||||
notes: initialData?.notes || '',
|
||||
},
|
||||
})
|
||||
|
||||
const onFormSubmit = (data: FormData) => {
|
||||
onSubmit({
|
||||
...data,
|
||||
email: data.email || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
|
||||
{/* Supplier Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>Leverantörstyp *</Label>
|
||||
<Controller
|
||||
name="supplier_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj typ" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
|
||||
<SelectItem value="eu_business">EU-företag</SelectItem>
|
||||
<SelectItem value="non_eu_business">Företag utanför EU</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Namn *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Leverantörens namn"
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-post</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="fakturor@foretag.se"
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="+46 8 123 45 67"
|
||||
{...register('phone')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business info */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="font-medium">Företagsuppgifter</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
placeholder="XXXXXX-XXXX"
|
||||
{...register('org_number')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vat_number">VAT-nummer</Label>
|
||||
<Input
|
||||
id="vat_number"
|
||||
placeholder="SE123456789001"
|
||||
{...register('vat_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="font-medium">Adress</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Gatuadress</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
placeholder="Storgatan 1"
|
||||
{...register('address_line1')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input id="postal_code" placeholder="123 45" {...register('postal_code')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input id="city" placeholder="Stockholm" {...register('city')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="country">Land</Label>
|
||||
<Input id="country" placeholder="SE" {...register('country')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment details */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="font-medium">Betalningsuppgifter</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bankgiro">Bankgiro</Label>
|
||||
<Input id="bankgiro" placeholder="XXX-XXXX" {...register('bankgiro')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plusgiro">Plusgiro</Label>
|
||||
<Input id="plusgiro" placeholder="XXXXXXX-X" {...register('plusgiro')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
<Input id="iban" placeholder="SE00 0000 0000 0000 0000 0000" {...register('iban')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bic">BIC/SWIFT</Label>
|
||||
<Input id="bic" placeholder="SWEDSESS" {...register('bic')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defaults */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="font-medium">Standardvärden</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_expense_account">Kostnadskonto</Label>
|
||||
<Input
|
||||
id="default_expense_account"
|
||||
placeholder="5010"
|
||||
{...register('default_expense_account')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Standardkonto för denna leverantör</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_terms">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="payment_terms"
|
||||
type="number"
|
||||
{...register('default_payment_terms', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_currency">Valuta</Label>
|
||||
<Controller
|
||||
name="default_currency"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SEK">SEK</SelectItem>
|
||||
<SelectItem value="EUR">EUR</SelectItem>
|
||||
<SelectItem value="USD">USD</SelectItem>
|
||||
<SelectItem value="GBP">GBP</SelectItem>
|
||||
<SelectItem value="NOK">NOK</SelectItem>
|
||||
<SelectItem value="DKK">DKK</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Anteckningar</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Interna anteckningar..."
|
||||
{...register('notes')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara leverantör'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -100,14 +100,14 @@ export function EmptyInvoices() {
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyCustomers() {
|
||||
export function EmptyCustomers({ onAction }: { onAction?: () => void } = {}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="Inga kunder ännu"
|
||||
description="Lägg till dina kunder för att enkelt skapa fakturor och hålla koll på betalningar."
|
||||
actionLabel="Lägg till kund"
|
||||
actionHref="/customers/new"
|
||||
onAction={onAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ProgressSegment {
|
||||
value: number
|
||||
color: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface SegmentedProgressProps {
|
||||
segments: ProgressSegment[]
|
||||
total: number
|
||||
height?: string
|
||||
className?: string
|
||||
showLabels?: boolean
|
||||
}
|
||||
|
||||
export function SegmentedProgress({
|
||||
segments,
|
||||
total,
|
||||
height = 'h-2',
|
||||
className,
|
||||
showLabels = false,
|
||||
}: SegmentedProgressProps) {
|
||||
const percentages = segments.map((segment) => ({
|
||||
...segment,
|
||||
percentage: total > 0 ? (segment.value / total) * 100 : 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden rounded-full bg-secondary',
|
||||
height
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full w-full">
|
||||
{percentages.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'h-full transition-all duration-700 ease-out',
|
||||
index === 0 && 'rounded-l-full',
|
||||
index === percentages.length - 1 && 'rounded-r-full'
|
||||
)}
|
||||
style={{
|
||||
width: `${segment.percentage}%`,
|
||||
backgroundColor: segment.color,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{showLabels && (
|
||||
<div className="flex justify-between mt-2 text-xs">
|
||||
{percentages.map((segment, index) => (
|
||||
<div key={index} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: segment.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{segment.label || `${segment.percentage.toFixed(0)}%`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Nordic color scheme for progress bars
|
||||
export const PROGRESS_COLORS = {
|
||||
success: 'hsl(150 30% 40%)', // Forest green
|
||||
warning: 'hsl(38 70% 55%)', // Warm honey
|
||||
destructive: 'hsl(0 45% 55%)', // Muted rose
|
||||
primary: 'hsl(145 20% 36%)', // Sage
|
||||
muted: 'hsl(30 8% 45%)', // Stone
|
||||
// Tax display - Nordic palette
|
||||
disponibelt: '#4d7c65', // Sage green
|
||||
skatt: '#c17f59', // Terracotta
|
||||
moms: '#b8956c', // Warm honey
|
||||
}
|
||||
|
||||
interface TaxProgressProps {
|
||||
disponibelt: number
|
||||
skattReservation: number
|
||||
momsReservation: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TaxProgress({
|
||||
disponibelt,
|
||||
skattReservation,
|
||||
momsReservation,
|
||||
className,
|
||||
}: TaxProgressProps) {
|
||||
const total = disponibelt + skattReservation + momsReservation
|
||||
|
||||
const segments: ProgressSegment[] = [
|
||||
{
|
||||
value: disponibelt,
|
||||
color: PROGRESS_COLORS.disponibelt,
|
||||
label: 'Disponibelt',
|
||||
},
|
||||
{
|
||||
value: skattReservation,
|
||||
color: PROGRESS_COLORS.skatt,
|
||||
label: 'Skatt',
|
||||
},
|
||||
]
|
||||
|
||||
if (momsReservation > 0) {
|
||||
segments.push({
|
||||
value: momsReservation,
|
||||
color: PROGRESS_COLORS.moms,
|
||||
label: 'Moms',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SegmentedProgress
|
||||
segments={segments}
|
||||
total={total}
|
||||
height="h-3"
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user