feat: overhaul all 12 sector extensions with full CRUD, validation, and tests
Add shared components (ConfirmDeleteDialog, EditEntryDialog, validation utils), enhance all 12 extension workspaces with edit/delete dialogs, input validation, period comparisons, and new analytics features. Fix critical bugs in ProjectBilling margin calculation and EarningsPerLiter revenue allocation. Add pure calculation modules with 183 new tests across all extensions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface ConfirmDeleteDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title?: string
|
||||
description?: string
|
||||
onConfirm: () => void | Promise<void>
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
export default function ConfirmDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title = 'Bekrafta borttagning',
|
||||
description = 'Ar du saker pa att du vill ta bort detta? Atgarden kan inte angras.',
|
||||
onConfirm,
|
||||
isDeleting = false,
|
||||
}: ConfirmDeleteDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-950/30">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isDeleting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirm} disabled={isDeleting}>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Tar bort...
|
||||
</>
|
||||
) : (
|
||||
'Ta bort'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Upload, FileText, Check } from 'lucide-react'
|
||||
|
||||
interface CsvImportWizardProps {
|
||||
targetFields: { key: string; label: string; required?: boolean }[]
|
||||
defaultMappings?: Record<string, string>
|
||||
onImport: (rows: Record<string, string>[]) => Promise<void>
|
||||
className?: string
|
||||
}
|
||||
|
||||
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim())
|
||||
if (lines.length === 0) return { headers: [], rows: [] }
|
||||
|
||||
const separator = lines[0].includes(';') ? ';' : ','
|
||||
const headers = lines[0].split(separator).map(h => h.trim().replace(/^"(.*)"$/, '$1'))
|
||||
const rows = lines.slice(1).map(line =>
|
||||
line.split(separator).map(cell => cell.trim().replace(/^"(.*)"$/, '$1'))
|
||||
)
|
||||
return { headers, rows }
|
||||
}
|
||||
|
||||
export default function CsvImportWizard({
|
||||
targetFields,
|
||||
defaultMappings,
|
||||
onImport,
|
||||
className,
|
||||
}: CsvImportWizardProps) {
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1)
|
||||
const [headers, setHeaders] = useState<string[]>([])
|
||||
const [rows, setRows] = useState<string[][]>([])
|
||||
const [mappings, setMappings] = useState<Record<string, string>>({})
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [importCount, setImportCount] = useState(0)
|
||||
const [fileName, setFileName] = useState('')
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setFileName(file.name)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string
|
||||
const parsed = parseCsv(text)
|
||||
setHeaders(parsed.headers)
|
||||
setRows(parsed.rows)
|
||||
|
||||
// Auto-map using defaults
|
||||
const autoMappings: Record<string, string> = {}
|
||||
for (const field of targetFields) {
|
||||
const defaultCsv = defaultMappings?.[field.key]
|
||||
if (defaultCsv && parsed.headers.includes(defaultCsv)) {
|
||||
autoMappings[field.key] = defaultCsv
|
||||
} else {
|
||||
const match = parsed.headers.find(
|
||||
h => h.toLowerCase() === field.key.toLowerCase() ||
|
||||
h.toLowerCase() === field.label.toLowerCase()
|
||||
)
|
||||
if (match) autoMappings[field.key] = match
|
||||
}
|
||||
}
|
||||
setMappings(autoMappings)
|
||||
setStep(2)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [targetFields, defaultMappings])
|
||||
|
||||
const handleImport = async () => {
|
||||
setIsImporting(true)
|
||||
try {
|
||||
const mappedRows = rows.map(row => {
|
||||
const obj: Record<string, string> = {}
|
||||
for (const [fieldKey, csvCol] of Object.entries(mappings)) {
|
||||
const colIdx = headers.indexOf(csvCol)
|
||||
if (colIdx >= 0 && row[colIdx]) {
|
||||
obj[fieldKey] = row[colIdx]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}).filter(row => Object.keys(row).length > 0)
|
||||
|
||||
await onImport(mappedRows)
|
||||
setImportCount(mappedRows.length)
|
||||
setStep(3)
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setStep(1)
|
||||
setHeaders([])
|
||||
setRows([])
|
||||
setMappings({})
|
||||
setFileName('')
|
||||
setImportCount(0)
|
||||
}
|
||||
|
||||
const requiredFieldsMapped = targetFields
|
||||
.filter(f => f.required)
|
||||
.every(f => mappings[f.key])
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{step === 1 && <><Upload className="h-4 w-4" /> Steg 1: Valj fil</>}
|
||||
{step === 2 && <><FileText className="h-4 w-4" /> Steg 2: Kolumnmappning</>}
|
||||
{step === 3 && <><Check className="h-4 w-4" /> Import klar</>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed rounded-lg p-8 text-center">
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Valj en CSV-fil att importera
|
||||
</p>
|
||||
<Label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<span>Valj fil</span>
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="csv-upload"
|
||||
type="file"
|
||||
accept=".csv,.txt"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fileName} - {rows.length} rader hittades. Mappa kolumner:
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{targetFields.map(field => (
|
||||
<div key={field.key} className="flex items-center gap-3">
|
||||
<Label className="w-36 text-sm shrink-0">
|
||||
{field.label}{field.required && ' *'}
|
||||
</Label>
|
||||
<Select
|
||||
value={mappings[field.key] ?? ''}
|
||||
onValueChange={(val) => setMappings(prev => ({ ...prev, [field.key]: val }))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Valj kolumn..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{headers.map(h => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="rounded-lg border overflow-auto max-h-48">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{headers.map(h => (
|
||||
<TableHead key={h} className="text-xs whitespace-nowrap">{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.slice(0, 5).map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<TableCell key={j} className="text-xs whitespace-nowrap">{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={reset}>
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
disabled={!requiredFieldsMapped || isImporting}
|
||||
>
|
||||
{isImporting ? 'Importerar...' : `Importera ${rows.length} rader`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-100 mb-3">
|
||||
<Check className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<p className="font-medium">{importCount} rader importerades</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Fran {fileName}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={reset} className="mt-4">
|
||||
Importera fler
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface EditEntryDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
onSave: () => void | Promise<void>
|
||||
isSaving?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function EditEntryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
children,
|
||||
}: EditEntryDialogProps) {
|
||||
const handleSave = async () => {
|
||||
await onSave()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MonthlyRow {
|
||||
month: string
|
||||
value: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface MonthlyTrendTableProps {
|
||||
rows: MonthlyRow[]
|
||||
valueLabel?: string
|
||||
valueSuffix?: string
|
||||
formatValue?: (v: number) => string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function MonthlyTrendTable({
|
||||
rows,
|
||||
valueLabel = 'Belopp',
|
||||
valueSuffix = 'kr',
|
||||
formatValue,
|
||||
className,
|
||||
}: MonthlyTrendTableProps) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className={cn('rounded-xl border p-6 text-center text-sm text-muted-foreground', className)}>
|
||||
Ingen data att visa
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...rows.map(r => Math.abs(r.value)), 1)
|
||||
const fmt = formatValue ?? ((v: number) => v.toLocaleString('sv-SE'))
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-xl border', className)}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead className="text-right">{valueLabel}</TableHead>
|
||||
<TableHead className="w-[40%]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const barWidth = Math.round((Math.abs(row.value) / maxValue) * 100)
|
||||
return (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell className="font-medium">{row.label ?? row.month}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{fmt(row.value)} {valueSuffix}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-full rounded-sm bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-sm bg-primary/60 transition-all"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface SetupField {
|
||||
key: string
|
||||
label: string
|
||||
type?: 'number' | 'text'
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface SetupPromptProps {
|
||||
title: string
|
||||
description: string
|
||||
fields: SetupField[]
|
||||
onSave: (values: Record<string, string>) => Promise<void>
|
||||
}
|
||||
|
||||
export default function SetupPrompt({ title, description, fields, onSave }: SetupPromptProps) {
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave(values)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const allFilled = fields.every(f => values[f.key]?.trim())
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-muted mb-3">
|
||||
<Settings className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{fields.map(field => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={`setup-${field.key}`}>{field.label}</Label>
|
||||
<Input
|
||||
id={`setup-${field.key}`}
|
||||
type={field.type ?? 'text'}
|
||||
placeholder={field.placeholder}
|
||||
value={values[field.key] ?? ''}
|
||||
onChange={e => setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button type="submit" className="w-full" disabled={!allFilled || isSaving}>
|
||||
{isSaving ? 'Sparar...' : 'Kom igang'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user