refactor(ui): form primitive polish + invoice page grid alignment (#475)

* refactor(ui): form primitive polish + invoice page grid alignment

Three things that were dropped from the previous design-refresh squash
and one new fix on invoices/[id].

Form primitives — tighter focus rings, match Button transition cadence:
- Input/Textarea/Select trigger: transition-colors duration-200 → 150
- Focus ring: border-primary/{50,60} + ring-primary/{20,35} →
  border-primary + ring-primary/20 (full-opacity border with a
  faint glow ring; cleaner than the opacity-stacked version)
- SelectContent: border-border/60 → border-border, shadow-md →
  halved --shadow-md token
- SelectSeparator: bg-border/50 → bg-border

invoices/new — single grid with explicit row placement so the
second-row cards (Anteckningar / Summering) align across columns
regardless of how tall the first-row cards end up:
- Flatten the two column wrappers into one grid
- Add lg:auto-rows-min lg:items-start
- Each card pinned with lg:col-{start,span} + lg:row-start
- Fakturarader now spans col 1-3 (full width) on lg+

invoices/[id] — same alignment fix on the detail view:
- Flatten the column wrappers into one grid
- Kund (col 1-2) + Fakturarader (col 1-2) + Anteckningar (col 1-2,
  conditional) pinned to rows 1-3
- All right-side cards (Detaljer + conditional status cards) stay
  grouped in a single wrapper at lg:col-start-3 lg:row-span-3, so
  they stack naturally without disrupting the left-column rows

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(ui): address PR review

- invoices/[id]: add lg:auto-rows-min to match invoices/new and prevent
  unintended row expansion from any future auto-placed items
- Clean up stale indentation in both invoice pages — Card children
  were 2 spaces too deep after the wrapper divs were removed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-14 11:07:01 +02:00
committed by GitHub
parent c8461397c8
commit dd28a379c9
5 changed files with 490 additions and 499 deletions
+152 -155
View File
@@ -468,178 +468,175 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Customer info */}
<Card>
<CardHeader>
<CardTitle>Kund</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="font-medium text-lg">{customer.name}</p>
{customer.org_number && (
<p className="text-muted-foreground">Org.nr: {customer.org_number}</p>
<div className="grid gap-6 lg:grid-cols-3 lg:auto-rows-min lg:items-start">
{/* Customer info */}
<Card className="lg:col-span-2 lg:row-start-1">
<CardHeader>
<CardTitle>Kund</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="font-medium text-lg">{customer.name}</p>
{customer.org_number && (
<p className="text-muted-foreground">Org.nr: {customer.org_number}</p>
)}
{customer.vat_number && (
<p className="text-muted-foreground">VAT: {customer.vat_number}</p>
)}
<div className="flex flex-wrap gap-4 pt-2 text-sm text-muted-foreground">
{customer.email && (
<span>{customer.email}</span>
)}
{customer.vat_number && (
<p className="text-muted-foreground">VAT: {customer.vat_number}</p>
{customer.phone && (
<span>{customer.phone}</span>
)}
<div className="flex flex-wrap gap-4 pt-2 text-sm text-muted-foreground">
{customer.email && (
<span>{customer.email}</span>
)}
{customer.phone && (
<span>{customer.phone}</span>
)}
</div>
{(customer.address_line1 || customer.city) && (
<div className="text-sm text-muted-foreground pt-1">
{customer.address_line1 && <p>{customer.address_line1}</p>}
{customer.address_line2 && <p>{customer.address_line2}</p>}
<p>
{customer.postal_code} {customer.city}
{customer.country !== 'SE' && `, ${customer.country}`}
</p>
</div>
{(customer.address_line1 || customer.city) && (
<div className="text-sm text-muted-foreground pt-1">
{customer.address_line1 && <p>{customer.address_line1}</p>}
{customer.address_line2 && <p>{customer.address_line2}</p>}
<p>
{customer.postal_code} {customer.city}
{customer.country !== 'SE' && `, ${customer.country}`}
)}
</div>
</CardContent>
</Card>
{/* Invoice items */}
<Card className="lg:col-span-2 lg:row-start-2">
<CardHeader>
<CardTitle>Fakturarader</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Header — desktop */}
<div className="hidden sm:grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
<div className="col-span-5">Beskrivning</div>
<div className="col-span-2 text-right">Antal</div>
<div className="col-span-1 text-center">Enhet</div>
<div className="col-span-2 text-right">à-pris</div>
<div className="col-span-2 text-right">Summa</div>
</div>
{/* Items — desktop */}
<div className="hidden sm:block space-y-4">
{invoice.items.map((item) => (
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
<div className="col-span-5">{item.description}</div>
<div className="col-span-2 text-right">{item.quantity}</div>
<div className="col-span-1 text-center">{item.unit}</div>
<div className="col-span-2 text-right">
{formatCurrency(item.unit_price, invoice.currency)}
</div>
<div className="col-span-2 text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
</div>
</div>
))}
</div>
{/* Items — mobile cards */}
<div className="sm:hidden space-y-2">
{invoice.items.map((item) => (
<div key={item.id} className="border rounded-lg p-3 text-sm space-y-1.5">
<p className="font-medium">{item.description}</p>
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}</span>
</div>
<p className="text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
</p>
</div>
)}
))}
</div>
</CardContent>
</Card>
{/* Invoice items */}
<Card>
<CardHeader>
<CardTitle>Fakturarader</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Header — desktop */}
<div className="hidden sm:grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
<div className="col-span-5">Beskrivning</div>
<div className="col-span-2 text-right">Antal</div>
<div className="col-span-1 text-center">Enhet</div>
<div className="col-span-2 text-right">à-pris</div>
<div className="col-span-2 text-right">Summa</div>
<Separator />
{/* Totals */}
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Delsumma</span>
<span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
{(() => {
const vatByRate = new Map<number, number>()
for (const item of invoice.items) {
const rate = item.vat_rate ?? 0
const lineVat = Math.round(item.line_total * (rate / 100) * 100) / 100
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
}
const entries = Array.from(vatByRate.entries())
.filter(([, vat]) => vat > 0)
.sort(([a], [b]) => b - a)
{/* Items — desktop */}
<div className="hidden sm:block space-y-4">
{invoice.items.map((item) => (
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
<div className="col-span-5">{item.description}</div>
<div className="col-span-2 text-right">{item.quantity}</div>
<div className="col-span-1 text-center">{item.unit}</div>
<div className="col-span-2 text-right">
{formatCurrency(item.unit_price, invoice.currency)}
</div>
<div className="col-span-2 text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
</div>
</div>
))}
</div>
{/* Items — mobile cards */}
<div className="sm:hidden space-y-2">
{invoice.items.map((item) => (
<div key={item.id} className="border rounded-lg p-3 text-sm space-y-1.5">
<p className="font-medium">{item.description}</p>
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}</span>
</div>
<p className="text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
</p>
</div>
))}
</div>
<Separator />
{/* Totals */}
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Delsumma</span>
<span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
{(() => {
const vatByRate = new Map<number, number>()
for (const item of invoice.items) {
const rate = item.vat_rate ?? 0
const lineVat = Math.round(item.line_total * (rate / 100) * 100) / 100
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
}
const entries = Array.from(vatByRate.entries())
.filter(([, vat]) => vat > 0)
.sort(([a], [b]) => b - a)
if (entries.length === 0) {
return (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatCurrency(0, invoice.currency)}</span>
</div>
)
}
return entries.map(([rate, vat]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span>{formatCurrency(vat, invoice.currency)}</span>
</div>
))
})()}
<Separator />
{(() => {
const rounding = getDisplayTotal(invoice, { ore_rounding: oreRounding })
if (entries.length === 0) {
return (
<>
{rounding.applies && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>Öresavrundning</span>
<span>{formatCurrency(rounding.roundingDelta, 'SEK')}</span>
</div>
)}
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
<span>{formatCurrency(rounding.displayed, invoice.currency)}</span>
</div>
</>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatCurrency(0, invoice.currency)}</span>
</div>
)
})()}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>I SEK (kurs {invoice.exchange_rate})</span>
<span>{formatCurrency(invoice.total_sek)}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
}
{/* Notes */}
{(invoice.notes || invoice.reverse_charge_text) && (
<Card>
<CardHeader>
<CardTitle>Anteckningar</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{invoice.reverse_charge_text && (
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm font-medium">Omvänd skattskyldighet</p>
<p className="text-sm text-muted-foreground">{invoice.reverse_charge_text}</p>
return entries.map(([rate, vat]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span>{formatCurrency(vat, invoice.currency)}</span>
</div>
))
})()}
<Separator />
{(() => {
const rounding = getDisplayTotal(invoice, { ore_rounding: oreRounding })
return (
<>
{rounding.applies && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>Öresavrundning</span>
<span>{formatCurrency(rounding.roundingDelta, 'SEK')}</span>
</div>
)}
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
<span>{formatCurrency(rounding.displayed, invoice.currency)}</span>
</div>
</>
)
})()}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>I SEK (kurs {invoice.exchange_rate})</span>
<span>{formatCurrency(invoice.total_sek)}</span>
</div>
)}
{invoice.notes && <p className="text-sm">{invoice.notes}</p>}
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
{/* Notes */}
{(invoice.notes || invoice.reverse_charge_text) && (
<Card className="lg:col-span-2 lg:row-start-3">
<CardHeader>
<CardTitle>Anteckningar</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{invoice.reverse_charge_text && (
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm font-medium">Omvänd skattskyldighet</p>
<p className="text-sm text-muted-foreground">{invoice.reverse_charge_text}</p>
</div>
)}
{invoice.notes && <p className="text-sm">{invoice.notes}</p>}
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
<div className="lg:col-start-3 lg:row-start-1 lg:row-span-3 space-y-6">
{/* Invoice details */}
<Card>
<CardHeader>
+333 -339
View File
@@ -473,157 +473,117 @@ export default function NewInvoicePage() {
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pb-28 md:pb-0">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Customer selection */}
<Card>
<CardHeader>
<CardTitle>Kund<RequiredMark /></CardTitle>
<CardDescription>Välj vilken kund fakturan ska skickas till</CardDescription>
</CardHeader>
<CardContent>
<Controller
name="customer_id"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Välj kund" />
</SelectTrigger>
<SelectContent>
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => setIsCreateCustomerOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Skapa kund
</Button>
{errors.customer_id && (
<p className="text-sm text-destructive mt-2">{errors.customer_id.message}</p>
<div className="grid gap-6 lg:grid-cols-3 lg:auto-rows-min lg:items-start">
{/* Customer selection */}
<Card className="lg:col-span-2 lg:row-start-1">
<CardHeader>
<CardTitle>Kund<RequiredMark /></CardTitle>
<CardDescription>Välj vilken kund fakturan ska skickas till</CardDescription>
</CardHeader>
<CardContent>
<Controller
name="customer_id"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Välj kund" />
</SelectTrigger>
<SelectContent>
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => setIsCreateCustomerOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Skapa kund
</Button>
{errors.customer_id && (
<p className="text-sm text-destructive mt-2">{errors.customer_id.message}</p>
)}
</CardContent>
</Card>
</CardContent>
</Card>
{/* Invoice items */}
<Card>
<CardHeader>
<CardTitle>Fakturarader</CardTitle>
<CardDescription>Lägg till produkter eller tjänster</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{fields.map((field, index) => {
const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0)
const lineVat = Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100
return (
<div
key={field.id}
className="rounded-lg border bg-card p-4 space-y-3 relative md:rounded-none md:border-0 md:bg-transparent md:p-0 md:space-y-0 md:grid md:grid-cols-12 md:gap-4 md:items-start"
>
{/* Description + mobile delete button */}
<div className="flex items-start gap-2 md:contents">
<div className="flex-1 space-y-1 md:col-span-3 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Beskrivning</Label>
<Input
placeholder="T.ex. Instagram-kampanj"
{...register(`items.${index}.description`)}
/>
{errors.items?.[index]?.description && (
<p className="text-sm text-destructive">
{errors.items[index].description?.message}
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1 md:hidden"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
{/* Invoice items */}
<Card className="lg:col-span-3 lg:row-start-2">
<CardHeader>
<CardTitle>Fakturarader</CardTitle>
<CardDescription>Lägg till produkter eller tjänster</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{fields.map((field, index) => {
const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0)
const lineVat = Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100
return (
<div
key={field.id}
className="rounded-lg border bg-card p-4 space-y-3 relative md:rounded-none md:border-0 md:bg-transparent md:p-0 md:space-y-0 md:grid md:grid-cols-12 md:gap-4 md:items-start"
>
{/* Description + mobile delete button */}
<div className="flex items-start gap-2 md:contents">
<div className="flex-1 space-y-1 md:col-span-3 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Beskrivning</Label>
<Input
placeholder="T.ex. Instagram-kampanj"
{...register(`items.${index}.description`)}
/>
{errors.items?.[index]?.description && (
<p className="text-sm text-destructive">
{errors.items[index].description?.message}
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 min-h-[44px] min-w-[44px] -mr-2 -mt-1 md:hidden"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Antal, Enhet, à-pris */}
<div className="grid grid-cols-3 gap-2 md:contents">
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Antal</Label>
<Input
type="number"
step="0.01"
inputMode="decimal"
className="text-right tabular-nums"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
</div>
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Enhet</Label>
<Controller
name={`items.${index}.unit`}
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{units.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">à-pris</Label>
<Input
type="number"
step="any"
inputMode="decimal"
className="text-right tabular-nums"
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
/>
</div>
</div>
{/* Moms */}
{/* Antal, Enhet, à-pris */}
<div className="grid grid-cols-3 gap-2 md:contents">
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Moms</Label>
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Antal</Label>
<Input
type="number"
step="0.01"
inputMode="decimal"
className="text-right tabular-nums"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
</div>
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Enhet</Label>
<Controller
name={`items.${index}.vat_rate`}
name={`items.${index}.unit`}
control={control}
render={({ field }) => (
<Select
value={String(field.value ?? 25)}
onValueChange={(v) => field.onChange(Number(v))}
disabled={isRateLocked}
>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableRates.map((opt) => (
<SelectItem key={opt.rate} value={String(opt.rate)}>
{opt.label}
{units.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
@@ -631,214 +591,248 @@ export default function NewInvoicePage() {
)}
/>
</div>
{/* Desktop delete button */}
<div className="hidden md:flex md:col-span-1 md:items-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Mobile summary row */}
<div className="flex justify-between text-sm pt-1 border-t border-border/40 md:hidden">
<span className="text-muted-foreground">Rad {index + 1}</span>
<span className="font-medium tabular-nums">{formatCurrency(lineTotal + lineVat, watchCurrency)}</span>
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">à-pris</Label>
<Input
type="number"
step="any"
inputMode="decimal"
className="text-right tabular-nums"
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
/>
</div>
</div>
)
})}
<Button
type="button"
variant="outline"
className="w-full md:w-auto"
onClick={() =>
append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: availableRates[0]?.rate ?? 25 })
}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till rad
</Button>
</div>
</CardContent>
</Card>
{/* Moms */}
<div className="space-y-1 md:col-span-2 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Moms</Label>
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field }) => (
<Select
value={String(field.value ?? 25)}
onValueChange={(v) => field.onChange(Number(v))}
disabled={isRateLocked}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableRates.map((opt) => (
<SelectItem key={opt.rate} value={String(opt.rate)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
{/* Notes */}
<Card>
<CardHeader>
<CardTitle>Anteckningar</CardTitle>
<CardDescription>Valfritt meddelande fakturan</CardDescription>
</CardHeader>
<CardContent>
<Textarea
placeholder="T.ex. betalningsvillkor eller tack för samarbetet..."
{...register('notes')}
/>
</CardContent>
</Card>
</div>
{/* Desktop delete button */}
<div className="hidden md:flex md:col-span-1 md:items-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Invoice details */}
<Card>
<CardHeader>
<CardTitle>Fakturadetaljer</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Dokumenttyp</Label>
<Controller
name="document_type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="invoice">Faktura</SelectItem>
<SelectItem value="proforma">Proformafaktura</SelectItem>
<SelectItem value="delivery_note">Följesedel</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Valuta</Label>
<Controller
name="currency"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((currency) => (
<SelectItem key={currency} value={currency}>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Fakturadatum<RequiredMark /></Label>
<Input type="date" {...register('invoice_date')} aria-required="true" />
</div>
<div className="space-y-2">
<Label>Förfallodatum<RequiredMark /></Label>
<Input type="date" {...register('due_date')} aria-required="true" />
</div>
{watchDocumentType === 'invoice' && (
<div className="space-y-2">
<Label>Leveransdatum</Label>
<Input type="date" {...register('delivery_date')} placeholder="Om det skiljer sig från fakturadatum" />
</div>
)}
<Separator />
<div className="space-y-2">
<Label>Er referens</Label>
<Controller
name="your_reference"
control={control}
render={({ field }) => (
<TagInput
value={field.value ?? ''}
onChange={field.onChange}
placeholder="Kontaktperson hos kund"
/>
)}
/>
</div>
<div className="space-y-2">
<Label>Vår referens</Label>
<Controller
name="our_reference"
control={control}
render={({ field }) => (
<TagInput
value={field.value ?? ''}
onChange={field.onChange}
placeholder="Ditt namn"
/>
)}
/>
</div>
</CardContent>
</Card>
{/* Summary */}
<Card>
<CardHeader>
<CardTitle>Summering</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">Delsumma</span>
<span>{formatCurrency(subtotal, watchCurrency)}</span>
</div>
{Array.from(vatByRate.entries())
.sort(([a], [b]) => b - a)
.map(([rate, group]) => (
<div key={rate}>
{vatByRate.size > 1 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Netto {rate}%</span>
<span>{formatCurrency(group.base, watchCurrency)}</span>
</div>
)}
{group.vat > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span>{formatCurrency(group.vat, watchCurrency)}</span>
</div>
)}
{/* Mobile summary row */}
<div className="flex justify-between text-sm pt-1 border-t border-border/40 md:hidden">
<span className="text-muted-foreground">Rad {index + 1}</span>
<span className="font-medium tabular-nums">{formatCurrency(lineTotal + lineVat, watchCurrency)}</span>
</div>
</div>
))}
{vatByRate.size === 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatCurrency(0, watchCurrency)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
<span>{formatCurrency(total, watchCurrency)}</span>
</div>
</CardContent>
</Card>
)
})}
{/* Actions — desktop/tablet only */}
<Button
type="submit"
className="w-full hidden md:block"
size="lg"
disabled={isSubmitting || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
Granska & skapa
</Button>
</div>
<Button
type="button"
variant="outline"
className="w-full md:w-auto"
onClick={() =>
append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: availableRates[0]?.rate ?? 25 })
}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till rad
</Button>
</div>
</CardContent>
</Card>
{/* Notes */}
<Card className="lg:col-span-2 lg:row-start-3">
<CardHeader>
<CardTitle>Anteckningar</CardTitle>
<CardDescription>Valfritt meddelande fakturan</CardDescription>
</CardHeader>
<CardContent>
<Textarea
placeholder="T.ex. betalningsvillkor eller tack för samarbetet..."
{...register('notes')}
/>
</CardContent>
</Card>
{/* Invoice details */}
<Card className="lg:col-start-3 lg:row-start-1">
<CardHeader>
<CardTitle>Fakturadetaljer</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Dokumenttyp</Label>
<Controller
name="document_type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="invoice">Faktura</SelectItem>
<SelectItem value="proforma">Proformafaktura</SelectItem>
<SelectItem value="delivery_note">Följesedel</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Valuta</Label>
<Controller
name="currency"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((currency) => (
<SelectItem key={currency} value={currency}>
{currency}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Fakturadatum<RequiredMark /></Label>
<Input type="date" {...register('invoice_date')} aria-required="true" />
</div>
<div className="space-y-2">
<Label>Förfallodatum<RequiredMark /></Label>
<Input type="date" {...register('due_date')} aria-required="true" />
</div>
{watchDocumentType === 'invoice' && (
<div className="space-y-2">
<Label>Leveransdatum</Label>
<Input type="date" {...register('delivery_date')} placeholder="Om det skiljer sig från fakturadatum" />
</div>
)}
<Separator />
<div className="space-y-2">
<Label>Er referens</Label>
<Controller
name="your_reference"
control={control}
render={({ field }) => (
<TagInput
value={field.value ?? ''}
onChange={field.onChange}
placeholder="Kontaktperson hos kund"
/>
)}
/>
</div>
<div className="space-y-2">
<Label>Vår referens</Label>
<Controller
name="our_reference"
control={control}
render={({ field }) => (
<TagInput
value={field.value ?? ''}
onChange={field.onChange}
placeholder="Ditt namn"
/>
)}
/>
</div>
</CardContent>
</Card>
{/* Summary */}
<Card className="lg:col-start-3 lg:row-start-3">
<CardHeader>
<CardTitle>Summering</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">Delsumma</span>
<span>{formatCurrency(subtotal, watchCurrency)}</span>
</div>
{Array.from(vatByRate.entries())
.sort(([a], [b]) => b - a)
.map(([rate, group]) => (
<div key={rate}>
{vatByRate.size > 1 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Netto {rate}%</span>
<span>{formatCurrency(group.base, watchCurrency)}</span>
</div>
)}
{group.vat > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span>{formatCurrency(group.vat, watchCurrency)}</span>
</div>
)}
</div>
))}
{vatByRate.size === 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatCurrency(0, watchCurrency)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
<span>{formatCurrency(total, watchCurrency)}</span>
</div>
</CardContent>
</Card>
{/* Actions — desktop/tablet only */}
<Button
type="submit"
className="w-full hidden md:block lg:col-start-3 lg:row-start-4"
size="lg"
disabled={isSubmitting || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
Granska & skapa
</Button>
</div>
{/* Mobile sticky total bar */}
+1 -1
View File
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-card px-4 py-2 text-sm transition-colors duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:border-primary/60 focus-visible:ring-1 focus-visible:ring-primary/35 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-lg border border-input bg-card px-4 py-2 text-sm transition-colors duration-150 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
+3 -3
View File
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-lg border border-input bg-card px-4 py-2 text-sm transition-colors duration-200 placeholder:text-muted-foreground/60 focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/35 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-10 w-full items-center justify-between rounded-lg border border-input bg-card px-4 py-2 text-sm transition-colors duration-150 placeholder:text-muted-foreground/60 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
@@ -74,7 +74,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border/60 bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-[var(--shadow-md)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
@@ -138,7 +138,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
+1 -1
View File
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
"flex min-h-[100px] w-full rounded-lg border border-input bg-card px-4 py-3 text-sm transition-colors duration-200 placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:border-primary/50 focus-visible:ring-1 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
"flex min-h-[100px] w-full rounded-lg border border-input bg-card px-4 py-3 text-sm transition-colors duration-150 placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:border-primary focus-visible:ring-1 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
className
)}
ref={ref}