- EU Sales List: engine, CSV/XML generators, Skatteverket format, tests - VAT Monitor: moms box mapping, revenue breakdown, validation, tests - Intrastat: product registry, SCB CSV generator, threshold tracking, tests - Currency Receivables: FX exposure, unrealized gain/loss calc, tests - Shared utilities: EU country list, moms box mapping - API routes for report generation and file downloads - Workspace UI components for all 4 extensions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
626 lines
26 KiB
TypeScript
626 lines
26 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
|
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
|
import { useExtensionData } from '@/lib/extensions/use-extension-data'
|
|
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Progress } from '@/components/ui/progress'
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
} from '@/components/ui/table'
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
AlertTriangle, Plus, Pencil, Trash2, FileSpreadsheet, Clock,
|
|
ChevronDown, ChevronUp, Package,
|
|
} from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
// ── Types ─────────────────────────────────────────────────────
|
|
|
|
interface IntrastatLine {
|
|
cnCode: string
|
|
partnerCountry: string
|
|
countryOfOrigin: string
|
|
transactionNature: string
|
|
deliveryTerms: string
|
|
invoicedValue: number
|
|
netMass: number
|
|
supplementaryUnit: number | null
|
|
supplementaryUnitType: string | null
|
|
partnerVatId: string
|
|
}
|
|
|
|
interface ThresholdStatus {
|
|
cumulativeValue: number
|
|
threshold: number
|
|
isObligated: boolean
|
|
percentageUsed: number
|
|
}
|
|
|
|
interface IntrastatWarning {
|
|
type: string
|
|
severity: 'error' | 'warning'
|
|
invoiceId?: string
|
|
invoiceNumber?: string
|
|
productId?: string
|
|
message: string
|
|
}
|
|
|
|
interface ReportData {
|
|
period: { year: number; month: number }
|
|
reporterVatNumber: string
|
|
reporterName: string
|
|
lines: IntrastatLine[]
|
|
totals: { invoicedValue: number; netMass: number; lineCount: number }
|
|
thresholdStatus: ThresholdStatus
|
|
warnings: IntrastatWarning[]
|
|
invoiceCount: number
|
|
}
|
|
|
|
interface ProductRecord {
|
|
key: string
|
|
productId: string
|
|
description: string
|
|
cn_code: string | null
|
|
net_weight_kg: number | null
|
|
country_of_origin: string
|
|
}
|
|
|
|
interface ProductForm {
|
|
productId: string
|
|
description: string
|
|
cnCode: string
|
|
netWeightKg: string
|
|
countryOfOrigin: string
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────
|
|
|
|
function formatSEK(amount: number): string {
|
|
return Math.round(amount).toLocaleString('sv-SE')
|
|
}
|
|
|
|
const MONTHS = [
|
|
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
|
|
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
|
|
]
|
|
|
|
function currentYear(): number { return new Date().getFullYear() }
|
|
function currentMonth(): number { return new Date().getMonth() + 1 }
|
|
|
|
const EMPTY_PRODUCT: ProductForm = {
|
|
productId: '', description: '', cnCode: '', netWeightKg: '', countryOfOrigin: 'SE',
|
|
}
|
|
|
|
// ── Component ─────────────────────────────────────────────────
|
|
|
|
export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) {
|
|
void userId
|
|
|
|
const [year, setYear] = useState(currentYear())
|
|
const [month, setMonth] = useState(currentMonth())
|
|
|
|
const [report, setReport] = useState<ReportData | null>(null)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [downloading, setDownloading] = useState(false)
|
|
|
|
const [warningsExpanded, setWarningsExpanded] = useState(false)
|
|
|
|
// Product CRUD
|
|
const { data: extData, save, remove, isLoading: productsLoading } = useExtensionData('export', 'intrastat')
|
|
const [productDialogOpen, setProductDialogOpen] = useState(false)
|
|
const [editingProduct, setEditingProduct] = useState<string | null>(null)
|
|
const [productForm, setProductForm] = useState<ProductForm>(EMPTY_PRODUCT)
|
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
|
|
|
const years = useMemo(() => {
|
|
const cy = currentYear()
|
|
return [cy, cy - 1, cy - 2]
|
|
}, [])
|
|
|
|
// Parse products from extension data
|
|
const products: ProductRecord[] = useMemo(() => {
|
|
return extData
|
|
.filter(d => d.key.startsWith('product:'))
|
|
.map(d => ({
|
|
key: d.key,
|
|
productId: d.key.replace('product:', ''),
|
|
description: String(d.value.description || ''),
|
|
cn_code: d.value.cn_code ? String(d.value.cn_code) : null,
|
|
net_weight_kg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null,
|
|
country_of_origin: String(d.value.country_of_origin || 'SE'),
|
|
}))
|
|
.sort((a, b) => a.description.localeCompare(b.description))
|
|
}, [extData])
|
|
|
|
// Fetch report
|
|
const fetchReport = useCallback(async () => {
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const params = new URLSearchParams({ year: String(year), month: String(month) })
|
|
const res = await fetch(`/api/extensions/export/intrastat/report?${params}`)
|
|
if (!res.ok) {
|
|
const json = await res.json()
|
|
setError(json.error || 'Kunde inte generera rapporten')
|
|
setReport(null)
|
|
return
|
|
}
|
|
const json = await res.json()
|
|
setReport(json.data)
|
|
} catch {
|
|
setError('Nätverksfel')
|
|
setReport(null)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [year, month])
|
|
|
|
useEffect(() => {
|
|
fetchReport()
|
|
}, [fetchReport])
|
|
|
|
// Product CRUD handlers
|
|
const openNewProduct = () => {
|
|
setEditingProduct(null)
|
|
setProductForm(EMPTY_PRODUCT)
|
|
setProductDialogOpen(true)
|
|
}
|
|
|
|
const openEditProduct = (productId: string) => {
|
|
const product = products.find(p => p.productId === productId)
|
|
if (!product) return
|
|
setEditingProduct(productId)
|
|
setProductForm({
|
|
productId,
|
|
description: product.description,
|
|
cnCode: product.cn_code || '',
|
|
netWeightKg: product.net_weight_kg !== null ? String(product.net_weight_kg) : '',
|
|
countryOfOrigin: product.country_of_origin,
|
|
})
|
|
setProductDialogOpen(true)
|
|
}
|
|
|
|
const saveProduct = async () => {
|
|
const id = editingProduct || productForm.productId.trim()
|
|
if (!id) return
|
|
|
|
await save(`product:${id}`, {
|
|
description: productForm.description.trim(),
|
|
cn_code: productForm.cnCode.trim() || null,
|
|
net_weight_kg: productForm.netWeightKg ? parseFloat(productForm.netWeightKg) : null,
|
|
country_of_origin: productForm.countryOfOrigin || 'SE',
|
|
})
|
|
|
|
setProductDialogOpen(false)
|
|
// Refresh report to pick up new product metadata
|
|
fetchReport()
|
|
}
|
|
|
|
const deleteProduct = async (productId: string) => {
|
|
await remove(`product:${productId}`)
|
|
setDeleteConfirm(null)
|
|
fetchReport()
|
|
}
|
|
|
|
// Download handler
|
|
const handleDownload = async () => {
|
|
setDownloading(true)
|
|
try {
|
|
const params = new URLSearchParams({ year: String(year), month: String(month) })
|
|
const res = await fetch(`/api/extensions/export/intrastat/download?${params}`)
|
|
if (!res.ok) {
|
|
setError('Kunde inte ladda ner filen')
|
|
return
|
|
}
|
|
const blob = await res.blob()
|
|
const disposition = res.headers.get('Content-Disposition') || ''
|
|
const match = disposition.match(/filename="(.+)"/)
|
|
const filename = match ? match[1] : `INTRASTAT_${year}-${String(month).padStart(2, '0')}.csv`
|
|
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
} catch {
|
|
setError('Kunde inte ladda ner filen')
|
|
} finally {
|
|
setDownloading(false)
|
|
}
|
|
}
|
|
|
|
const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0
|
|
const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0
|
|
|
|
if ((isLoading || productsLoading) && !report) {
|
|
return <ExtensionLoadingSkeleton />
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* ── Period Selector ─────────────────────────────────── */}
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">År</label>
|
|
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
|
<SelectTrigger className="w-[100px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{years.map(y => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Månad</label>
|
|
<Select value={String(month)} onValueChange={v => setMonth(parseInt(v, 10))}>
|
|
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{MONTHS.map((name, i) => <SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex gap-2 ml-auto">
|
|
<Button
|
|
variant="outline" size="sm"
|
|
onClick={handleDownload}
|
|
disabled={downloading || !report || report.lines.length === 0}
|
|
>
|
|
<FileSpreadsheet className="h-4 w-4 mr-1.5" />
|
|
{downloading ? 'Laddar...' : 'IDEP.web CSV'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<Card className="border-destructive/50 bg-destructive/5">
|
|
<CardContent className="pt-6">
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{report && (
|
|
<>
|
|
{/* ── Threshold Progress ───────────────────────────── */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<p className="text-sm font-medium">Tröskelvärde Intrastat (utförsel)</p>
|
|
<Badge variant={report.thresholdStatus.isObligated ? 'destructive' : 'outline'}>
|
|
{report.thresholdStatus.isObligated ? 'Obligatorisk rapportering' : 'Frivillig rapportering'}
|
|
</Badge>
|
|
</div>
|
|
<Progress
|
|
value={Math.min(report.thresholdStatus.percentageUsed, 100)}
|
|
className="h-3"
|
|
/>
|
|
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
|
|
<span>
|
|
Ackumulerat (12 mån): {formatSEK(report.thresholdStatus.cumulativeValue)} SEK
|
|
</span>
|
|
<span>
|
|
{report.thresholdStatus.percentageUsed}% av {formatSEK(report.thresholdStatus.threshold)} SEK
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ── KPI Row ──────────────────────────────────────── */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-sm text-muted-foreground">Fakturerat värde</p>
|
|
<p className="text-2xl font-semibold tabular-nums mt-1">{formatSEK(report.totals.invoicedValue)}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">SEK</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-sm text-muted-foreground">Nettovikt</p>
|
|
<p className="text-2xl font-semibold tabular-nums mt-1">{report.totals.netMass.toLocaleString('sv-SE')}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">kg</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-sm text-muted-foreground">Deklarationsrader</p>
|
|
<p className="text-2xl font-semibold tabular-nums mt-1">{report.totals.lineCount}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">{report.invoiceCount} fakturor</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* ── Product Registry ─────────────────────────────── */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<Package className="h-4 w-4" />
|
|
Produktregister
|
|
</CardTitle>
|
|
<Button variant="outline" size="sm" onClick={openNewProduct}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
Lägg till
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{products.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-6 text-center">
|
|
Inga produkter registrerade. Lägg till produkter med CN-kod och vikt för att generera Intrastat-deklarationer.
|
|
</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Produkt</TableHead>
|
|
<TableHead>CN-kod</TableHead>
|
|
<TableHead className="text-right">Vikt (kg)</TableHead>
|
|
<TableHead>Ursprung</TableHead>
|
|
<TableHead className="w-20" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{products.map(p => (
|
|
<TableRow key={p.productId}>
|
|
<TableCell className="text-sm">
|
|
<div>
|
|
<span className="font-medium">{p.description || p.productId}</span>
|
|
{p.productId !== p.description && (
|
|
<span className="text-xs text-muted-foreground ml-1">({p.productId})</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{p.cn_code ? (
|
|
<Badge variant="outline" className="font-mono text-xs">{p.cn_code}</Badge>
|
|
) : (
|
|
<span className="text-destructive text-xs flex items-center gap-1">
|
|
<AlertTriangle className="h-3 w-3" /> Saknas
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono text-sm tabular-nums">
|
|
{p.net_weight_kg !== null
|
|
? String(p.net_weight_kg)
|
|
: <span className="text-muted-foreground">—</span>
|
|
}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="text-xs">
|
|
{p.country_of_origin}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex gap-1 justify-end">
|
|
<Button variant="ghost" size="sm" onClick={() => openEditProduct(p.productId)}>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(p.productId)}>
|
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ── Declaration Table ─────────────────────────────── */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">
|
|
Deklaration {MONTHS[month - 1]} {year}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{report.lines.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-6 text-center">
|
|
Inga EU-varuförsäljningar hittades för perioden.
|
|
</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>CN-kod</TableHead>
|
|
<TableHead>Land</TableHead>
|
|
<TableHead>Urspr.</TableHead>
|
|
<TableHead className="text-right">Värde (SEK)</TableHead>
|
|
<TableHead className="text-right">Vikt (kg)</TableHead>
|
|
<TableHead>Partner-VAT</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{report.lines.map((line, i) => (
|
|
<TableRow key={i}>
|
|
<TableCell>
|
|
<Badge
|
|
variant={line.cnCode === '00000000' ? 'destructive' : 'outline'}
|
|
className="font-mono text-xs"
|
|
>
|
|
{line.cnCode}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="text-xs">{line.partnerCountry}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-xs">{line.countryOfOrigin}</TableCell>
|
|
<TableCell className="text-right font-mono text-sm tabular-nums">
|
|
{formatSEK(line.invoicedValue)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono text-sm tabular-nums">
|
|
{line.netMass > 0 ? line.netMass.toLocaleString('sv-SE') : '—'}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs">{line.partnerVatId || '—'}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
<TableRow className="border-t-2 font-medium">
|
|
<TableCell colSpan={3} className="text-sm">Summa</TableCell>
|
|
<TableCell className="text-right font-mono text-sm tabular-nums">
|
|
{formatSEK(report.totals.invoicedValue)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono text-sm tabular-nums">
|
|
{report.totals.netMass.toLocaleString('sv-SE')}
|
|
</TableCell>
|
|
<TableCell />
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ── Warnings ─────────────────────────────────────── */}
|
|
{report.warnings.length > 0 && (
|
|
<Card className={cn('border-l-4', errorCount > 0 ? 'border-l-destructive' : 'border-l-warning')}>
|
|
<CardContent className="pt-6">
|
|
<button className="flex items-center gap-2 w-full text-left" onClick={() => setWarningsExpanded(!warningsExpanded)}>
|
|
<AlertTriangle className={cn('h-4 w-4 shrink-0', errorCount > 0 ? 'text-destructive' : 'text-warning-foreground')} />
|
|
<span className="text-sm font-medium flex-1">
|
|
{errorCount > 0 && <span className="text-destructive">{errorCount} fel</span>}
|
|
{errorCount > 0 && warningCount > 0 && ', '}
|
|
{warningCount > 0 && <span className="text-warning-foreground">{warningCount} varningar</span>}
|
|
</span>
|
|
{warningsExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
|
</button>
|
|
{warningsExpanded && (
|
|
<div className="mt-4 space-y-2">
|
|
{report.warnings.map((w, i) => (
|
|
<div key={i} className={cn('flex items-start gap-2 text-sm py-2 px-3 rounded-md', w.severity === 'error' ? 'bg-destructive/5 text-destructive' : 'bg-warning/10 text-warning-foreground')}>
|
|
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
|
<span>{w.message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* ── Deadline Footer ───────────────────────────────── */}
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
<span>
|
|
Deadline: 10:e arbetsdagen efter redovisningsperiodens slut
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ── Product Dialog ────────────────────────────────────── */}
|
|
<Dialog open={productDialogOpen} onOpenChange={setProductDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{editingProduct ? 'Redigera produkt' : 'Lägg till produkt'}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
{!editingProduct && (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="productId">Produkt-ID (SKU)</Label>
|
|
<Input
|
|
id="productId"
|
|
value={productForm.productId}
|
|
onChange={e => setProductForm(f => ({ ...f, productId: e.target.value }))}
|
|
placeholder="T.ex. STALBALK-M8"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="description">Beskrivning</Label>
|
|
<Input
|
|
id="description"
|
|
value={productForm.description}
|
|
onChange={e => setProductForm(f => ({ ...f, description: e.target.value }))}
|
|
placeholder="T.ex. Stålbalk M8 200mm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="cnCode">CN-kod (8 siffror)</Label>
|
|
<Input
|
|
id="cnCode"
|
|
value={productForm.cnCode}
|
|
onChange={e => setProductForm(f => ({ ...f, cnCode: e.target.value.replace(/\D/g, '').slice(0, 8) }))}
|
|
placeholder="T.ex. 72163100"
|
|
maxLength={8}
|
|
className="font-mono"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="weight">Nettovikt per enhet (kg)</Label>
|
|
<Input
|
|
id="weight"
|
|
type="number"
|
|
step="0.001"
|
|
value={productForm.netWeightKg}
|
|
onChange={e => setProductForm(f => ({ ...f, netWeightKg: e.target.value }))}
|
|
placeholder="45.5"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="origin">Ursprungsland</Label>
|
|
<Input
|
|
id="origin"
|
|
value={productForm.countryOfOrigin}
|
|
onChange={e => setProductForm(f => ({ ...f, countryOfOrigin: e.target.value.toUpperCase().slice(0, 2) }))}
|
|
placeholder="SE"
|
|
maxLength={2}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setProductDialogOpen(false)}>Avbryt</Button>
|
|
<Button
|
|
onClick={saveProduct}
|
|
disabled={!editingProduct && !productForm.productId.trim()}
|
|
>
|
|
{editingProduct ? 'Spara' : 'Lägg till'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Delete Confirmation ───────────────────────────────── */}
|
|
<Dialog open={deleteConfirm !== null} onOpenChange={() => setDeleteConfirm(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Ta bort produkt?</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
Är du säker på att du vill ta bort produkten “{deleteConfirm}”? Denna åtgärd kan inte ångras.
|
|
</p>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Avbryt</Button>
|
|
<Button variant="destructive" onClick={() => deleteConfirm && deleteProduct(deleteConfirm)}>
|
|
Ta bort
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|