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
co-authored by Claude Opus 4.7
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> </div>
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3 lg:auto-rows-min lg:items-start">
{/* Main content */} {/* Customer info */}
<div className="lg:col-span-2 space-y-6"> <Card className="lg:col-span-2 lg:row-start-1">
{/* Customer info */} <CardHeader>
<Card> <CardTitle>Kund</CardTitle>
<CardHeader> </CardHeader>
<CardTitle>Kund</CardTitle> <CardContent>
</CardHeader> <div className="space-y-2">
<CardContent> <p className="font-medium text-lg">{customer.name}</p>
<div className="space-y-2"> {customer.org_number && (
<p className="font-medium text-lg">{customer.name}</p> <p className="text-muted-foreground">Org.nr: {customer.org_number}</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 && ( {customer.phone && (
<p className="text-muted-foreground">VAT: {customer.vat_number}</p> <span>{customer.phone}</span>
)} )}
<div className="flex flex-wrap gap-4 pt-2 text-sm text-muted-foreground"> </div>
{customer.email && ( {(customer.address_line1 || customer.city) && (
<span>{customer.email}</span> <div className="text-sm text-muted-foreground pt-1">
)} {customer.address_line1 && <p>{customer.address_line1}</p>}
{customer.phone && ( {customer.address_line2 && <p>{customer.address_line2}</p>}
<span>{customer.phone}</span> <p>
)} {customer.postal_code} {customer.city}
{customer.country !== 'SE' && `, ${customer.country}`}
</p>
</div> </div>
{(customer.address_line1 || customer.city) && ( )}
<div className="text-sm text-muted-foreground pt-1"> </div>
{customer.address_line1 && <p>{customer.address_line1}</p>} </CardContent>
{customer.address_line2 && <p>{customer.address_line2}</p>} </Card>
<p>
{customer.postal_code} {customer.city} {/* Invoice items */}
{customer.country !== 'SE' && `, ${customer.country}`} <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> </p>
</div> </div>
)} ))}
</div> </div>
</CardContent>
</Card>
{/* Invoice items */} <Separator />
<Card>
<CardHeader> {/* Totals */}
<CardTitle>Fakturarader</CardTitle> <div className="space-y-2">
</CardHeader> <div className="flex justify-between">
<CardContent> <span className="text-muted-foreground">Delsumma</span>
<div className="space-y-4"> <span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
{/* 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> </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 */} if (entries.length === 0) {
<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 })
return ( return (
<> <div className="flex justify-between">
{rounding.applies && ( <span className="text-muted-foreground">Moms</span>
<div className="flex justify-between text-sm text-muted-foreground"> <span>{formatCurrency(0, invoice.currency)}</span>
<span>Öresavrundning</span> </div>
<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>
)}
</div>
</div>
</CardContent>
</Card>
{/* Notes */} return entries.map(([rate, vat]) => (
{(invoice.notes || invoice.reverse_charge_text) && ( <div key={rate} className="flex justify-between">
<Card> <span className="text-muted-foreground">Moms {rate}%</span>
<CardHeader> <span>{formatCurrency(vat, invoice.currency)}</span>
<CardTitle>Anteckningar</CardTitle> </div>
</CardHeader> ))
<CardContent className="space-y-4"> })()}
{invoice.reverse_charge_text && ( <Separator />
<div className="p-3 bg-muted rounded-lg"> {(() => {
<p className="text-sm font-medium">Omvänd skattskyldighet</p> const rounding = getDisplayTotal(invoice, { ore_rounding: oreRounding })
<p className="text-sm text-muted-foreground">{invoice.reverse_charge_text}</p> 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> </div>
)} )}
{invoice.notes && <p className="text-sm">{invoice.notes}</p>} </div>
</CardContent> </div>
</Card> </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 */} {/* 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 */} {/* Invoice details */}
<Card> <Card>
<CardHeader> <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"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pb-28 md:pb-0">
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3 lg:auto-rows-min lg:items-start">
{/* Main content */} {/* Customer selection */}
<div className="lg:col-span-2 space-y-6"> <Card className="lg:col-span-2 lg:row-start-1">
{/* Customer selection */} <CardHeader>
<Card> <CardTitle>Kund<RequiredMark /></CardTitle>
<CardHeader> <CardDescription>Välj vilken kund fakturan ska skickas till</CardDescription>
<CardTitle>Kund<RequiredMark /></CardTitle> </CardHeader>
<CardDescription>Välj vilken kund fakturan ska skickas till</CardDescription> <CardContent>
</CardHeader> <Controller
<CardContent> name="customer_id"
<Controller control={control}
name="customer_id" render={({ field }) => (
control={control} <Select value={field.value} onValueChange={field.onChange}>
render={({ field }) => ( <SelectTrigger>
<Select value={field.value} onValueChange={field.onChange}> <SelectValue placeholder="Välj kund" />
<SelectTrigger> </SelectTrigger>
<SelectValue placeholder="Välj kund" /> <SelectContent>
</SelectTrigger> {customers.map((customer) => (
<SelectContent> <SelectItem key={customer.id} value={customer.id}>
{customers.map((customer) => ( {customer.name}
<SelectItem key={customer.id} value={customer.id}> </SelectItem>
{customer.name} ))}
</SelectItem> </SelectContent>
))} </Select>
</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>
)} )}
/>
<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> </CardContent>
</Card> </Card>
{/* Invoice items */} {/* Invoice items */}
<Card> <Card className="lg:col-span-3 lg:row-start-2">
<CardHeader> <CardHeader>
<CardTitle>Fakturarader</CardTitle> <CardTitle>Fakturarader</CardTitle>
<CardDescription>Lägg till produkter eller tjänster</CardDescription> <CardDescription>Lägg till produkter eller tjänster</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{fields.map((field, index) => { {fields.map((field, index) => {
const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0) const lineTotal = (watchItems[index]?.quantity || 0) * (watchItems[index]?.unit_price || 0)
const lineVat = Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100 const lineVat = Math.round(lineTotal * (watchItems[index]?.vat_rate ?? 25) / 100 * 100) / 100
return ( return (
<div <div
key={field.id} 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" 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 */} {/* Description + mobile delete button */}
<div className="flex items-start gap-2 md:contents"> <div className="flex items-start gap-2 md:contents">
<div className="flex-1 space-y-1 md:col-span-3 md:space-y-2"> <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> <Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Beskrivning</Label>
<Input <Input
placeholder="T.ex. Instagram-kampanj" placeholder="T.ex. Instagram-kampanj"
{...register(`items.${index}.description`)} {...register(`items.${index}.description`)}
/> />
{errors.items?.[index]?.description && ( {errors.items?.[index]?.description && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
{errors.items[index].description?.message} {errors.items[index].description?.message}
</p> </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> </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 */} {/* Antal, Enhet, à-pris */}
<div className="grid grid-cols-3 gap-2 md:contents"> <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 */}
<div className="space-y-1 md:col-span-2 md:space-y-2"> <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 <Controller
name={`items.${index}.vat_rate`} name={`items.${index}.unit`}
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<Select <Select value={field.value} onValueChange={field.onChange}>
value={String(field.value ?? 25)}
onValueChange={(v) => field.onChange(Number(v))}
disabled={isRateLocked}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableRates.map((opt) => ( {units.map((unit) => (
<SelectItem key={opt.rate} value={String(opt.rate)}> <SelectItem key={unit} value={unit}>
{opt.label} {unit}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -631,214 +591,248 @@ export default function NewInvoicePage() {
)} )}
/> />
</div> </div>
<div className="space-y-1 md:col-span-2 md:space-y-2">
{/* Desktop delete button */} <Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">à-pris</Label>
<div className="hidden md:flex md:col-span-1 md:items-end"> <Input
<Button type="number"
type="button" step="any"
variant="ghost" inputMode="decimal"
size="icon" className="text-right tabular-nums"
onClick={() => remove(index)} {...register(`items.${index}.unit_price`, { valueAsNumber: true })}
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> </div>
</div> </div>
)
})}
<Button {/* Moms */}
type="button" <div className="space-y-1 md:col-span-2 md:space-y-2">
variant="outline" <Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">Moms</Label>
className="w-full md:w-auto" <Controller
onClick={() => name={`items.${index}.vat_rate`}
append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: availableRates[0]?.rate ?? 25 }) control={control}
} render={({ field }) => (
> <Select
<Plus className="mr-2 h-4 w-4" /> value={String(field.value ?? 25)}
Lägg till rad onValueChange={(v) => field.onChange(Number(v))}
</Button> disabled={isRateLocked}
</div> >
</CardContent> <SelectTrigger>
</Card> <SelectValue />
</SelectTrigger>
<SelectContent>
{availableRates.map((opt) => (
<SelectItem key={opt.rate} value={String(opt.rate)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
{/* Notes */} {/* Desktop delete button */}
<Card> <div className="hidden md:flex md:col-span-1 md:items-end">
<CardHeader> <Button
<CardTitle>Anteckningar</CardTitle> type="button"
<CardDescription>Valfritt meddelande på fakturan</CardDescription> variant="ghost"
</CardHeader> size="icon"
<CardContent> onClick={() => remove(index)}
<Textarea disabled={fields.length === 1}
placeholder="T.ex. betalningsvillkor eller tack för samarbetet..." >
{...register('notes')} <Trash2 className="h-4 w-4" />
/> </Button>
</CardContent> </div>
</Card>
</div>
{/* Sidebar */} {/* Mobile summary row */}
<div className="space-y-6"> <div className="flex justify-between text-sm pt-1 border-t border-border/40 md:hidden">
{/* Invoice details */} <span className="text-muted-foreground">Rad {index + 1}</span>
<Card> <span className="font-medium tabular-nums">{formatCurrency(lineTotal + lineVat, watchCurrency)}</span>
<CardHeader> </div>
<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>
)}
</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
<Button type="button"
type="submit" variant="outline"
className="w-full hidden md:block" className="w-full md:w-auto"
size="lg" onClick={() =>
disabled={isSubmitting || !canWrite} append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: availableRates[0]?.rate ?? 25 })
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined} }
> >
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />} <Plus className="mr-2 h-4 w-4" />
Granska & skapa Lägg till rad
</Button> </Button>
</div> </div>
</CardContent>
</Card>
{/* Notes */}
<Card className="lg:col-span-2 lg:row-start-3">
<CardHeader>
<CardTitle>Anteckningar</CardTitle>
<CardDescription>Valfritt meddelande på 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> </div>
{/* Mobile sticky total bar */} {/* Mobile sticky total bar */}
+1 -1
View File
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( 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 className
)} )}
ref={ref} ref={ref}
+3 -3
View File
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -74,7 +74,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( 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" && 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", "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 className
@@ -138,7 +138,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} 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} {...props}
/> />
)) ))
+1 -1
View File
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return ( return (
<textarea <textarea
className={cn( 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 className
)} )}
ref={ref} ref={ref}